Merge remote-tracking branch 'origin/main' into AI-109/persist-sidebar-state

Richard Feldman created

# Conflicts:
#	crates/sidebar/src/sidebar.rs

Change summary

.github/actions/run_tests/action.yml                                                 |    2 
.github/actions/run_tests_windows/action.yml                                         |    2 
.github/workflows/add_commented_closed_issue_to_project.yml                          |    2 
.github/workflows/after_release.yml                                                  |    4 
.github/workflows/assign-reviewers.yml                                               |    8 
.github/workflows/autofix_pr.yml                                                     |   16 
.github/workflows/background_agent_mvp.yml                                           |    4 
.github/workflows/bump_collab_staging.yml                                            |    2 
.github/workflows/bump_patch_version.yml                                             |   10 
.github/workflows/catch_blank_issues.yml                                             |    2 
.github/workflows/cherry_pick.yml                                                    |    8 
.github/workflows/comment_on_potential_duplicate_issues.yml                          |    4 
.github/workflows/community_champion_auto_labeler.yml                                |    2 
.github/workflows/community_update_all_top_ranking_issues.yml                        |    2 
.github/workflows/community_update_weekly_top_ranking_issues.yml                     |    2 
.github/workflows/compare_perf.yml                                                   |    4 
.github/workflows/congrats.yml                                                       |   11 
.github/workflows/danger.yml                                                         |    2 
.github/workflows/deploy_cloudflare.yml                                              |    2 
.github/workflows/deploy_collab.yml                                                  |   14 
.github/workflows/docs_suggestions.yml                                               |    4 
.github/workflows/extension_auto_bump.yml                                            |    2 
.github/workflows/extension_bump.yml                                                 |   30 
.github/workflows/extension_tests.yml                                                |   14 
.github/workflows/extension_workflow_rollout.yml                                     |   20 
.github/workflows/good_first_issue_notifier.yml                                      |    2 
.github/workflows/pr_labeler.yml                                                     |    2 
.github/workflows/publish_extension_cli.yml                                          |   22 
.github/workflows/randomized_tests.yml                                               |    2 
.github/workflows/release.yml                                                        |   48 
.github/workflows/release_nightly.yml                                                |   28 
.github/workflows/run_agent_evals.yml                                                |    4 
.github/workflows/run_bundling.yml                                                   |   20 
.github/workflows/run_cron_unit_evals.yml                                            |    6 
.github/workflows/run_tests.yml                                                      |   64 
.github/workflows/run_unit_evals.yml                                                 |    6 
.github/workflows/track_duplicate_bot_effectiveness.yml                              |    8 
.github/workflows/update_duplicate_magnets.yml                                       |    2 
Cargo.lock                                                                           |  120 
Cargo.toml                                                                           |    4 
assets/icons/sweep_ai.svg                                                            |    0 
assets/icons/sweep_ai_disabled.svg                                                   |    0 
assets/icons/sweep_ai_down.svg                                                       |    0 
assets/icons/sweep_ai_error.svg                                                      |    0 
assets/icons/sweep_ai_up.svg                                                         |    0 
assets/icons/threads_sidebar_right_closed.svg                                        |    5 
assets/icons/threads_sidebar_right_open.svg                                          |    5 
assets/keymaps/default-linux.json                                                    |    9 
assets/keymaps/default-macos.json                                                    |    9 
assets/keymaps/default-windows.json                                                  |    9 
assets/keymaps/vim.json                                                              |    2 
assets/settings/default.json                                                         |   11 
crates/acp_thread/src/acp_thread.rs                                                  |   66 
crates/acp_tools/src/acp_tools.rs                                                    |   35 
crates/agent/src/agent.rs                                                            |    3 
crates/agent/src/edit_agent/streaming_fuzzy_matcher.rs                               |   60 
crates/agent/src/tool_permissions.rs                                                 |    1 
crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-09.diff |   20 
crates/agent/src/tools/evals/streaming_edit_file.rs                                  |    2 
crates/agent/src/tools/streaming_edit_file_tool.rs                                   |   55 
crates/agent_settings/src/agent_settings.rs                                          |   16 
crates/agent_ui/src/agent_configuration.rs                                           |  587 
crates/agent_ui/src/agent_connection_store.rs                                        |  174 
crates/agent_ui/src/agent_panel.rs                                                   |   15 
crates/agent_ui/src/agent_ui.rs                                                      |    3 
crates/agent_ui/src/conversation_view.rs                                             |   24 
crates/agent_ui/src/conversation_view/thread_view.rs                                 |  791 
crates/agent_ui/src/entry_view_state.rs                                              |   18 
crates/agent_ui/src/threads_archive_view.rs                                          |   21 
crates/call/src/call_impl/mod.rs                                                     |   34 
crates/collab_ui/src/collab_panel.rs                                                 |  401 
crates/collab_ui/src/notification_panel.rs                                           |    2 
crates/debugger_ui/src/debugger_panel.rs                                             |    2 
crates/debugger_ui/src/session/running/variable_list.rs                              |    7 
crates/debugger_ui/src/tests/inline_values.rs                                        |   10 
crates/docs_preprocessor/src/main.rs                                                 |  106 
crates/edit_prediction/Cargo.toml                                                    |    1 
crates/edit_prediction/src/edit_prediction.rs                                        |  134 
crates/edit_prediction/src/sweep_ai.rs                                               |  669 
crates/edit_prediction/src/zed_edit_prediction_delegate.rs                           |   11 
crates/edit_prediction_cli/src/example.rs                                            |   31 
crates/edit_prediction_cli/src/filter_languages.rs                                   |    6 
crates/edit_prediction_cli/src/main.rs                                               |    5 
crates/edit_prediction_cli/src/metrics.rs                                            |  157 
crates/edit_prediction_cli/src/predict.rs                                            |    1 
crates/edit_prediction_cli/src/score.rs                                              |   81 
crates/edit_prediction_context/src/edit_prediction_context_tests.rs                  |    5 
crates/edit_prediction_ui/src/edit_prediction_button.rs                              |   25 
crates/editor/src/display_map.rs                                                     |    9 
crates/editor/src/editor.rs                                                          |   96 
crates/editor/src/semantic_tokens.rs                                                 |    5 
crates/editor/src/signature_help.rs                                                  |    3 
crates/extension_cli/src/main.rs                                                     |   25 
crates/git_ui/src/git_panel.rs                                                       |    2 
crates/gpui/Cargo.toml                                                               |    4 
crates/gpui/examples/list_example.rs                                                 |  170 
crates/gpui/src/elements/list.rs                                                     |  324 
crates/grammars/Cargo.toml                                                           |   60 
crates/grammars/LICENSE-GPL                                                          |    1 
crates/grammars/src/bash/brackets.scm                                                |    0 
crates/grammars/src/bash/config.toml                                                 |    0 
crates/grammars/src/bash/highlights.scm                                              |    0 
crates/grammars/src/bash/indents.scm                                                 |    0 
crates/grammars/src/bash/injections.scm                                              |    0 
crates/grammars/src/bash/overrides.scm                                               |    0 
crates/grammars/src/bash/redactions.scm                                              |    0 
crates/grammars/src/bash/runnables.scm                                               |    0 
crates/grammars/src/bash/textobjects.scm                                             |    0 
crates/grammars/src/c/brackets.scm                                                   |    0 
crates/grammars/src/c/config.toml                                                    |    0 
crates/grammars/src/c/highlights.scm                                                 |    0 
crates/grammars/src/c/imports.scm                                                    |    0 
crates/grammars/src/c/indents.scm                                                    |    0 
crates/grammars/src/c/injections.scm                                                 |    0 
crates/grammars/src/c/outline.scm                                                    |    0 
crates/grammars/src/c/overrides.scm                                                  |    0 
crates/grammars/src/c/runnables.scm                                                  |    0 
crates/grammars/src/c/textobjects.scm                                                |    0 
crates/grammars/src/cpp/brackets.scm                                                 |    0 
crates/grammars/src/cpp/config.toml                                                  |    0 
crates/grammars/src/cpp/highlights.scm                                               |    0 
crates/grammars/src/cpp/imports.scm                                                  |    0 
crates/grammars/src/cpp/indents.scm                                                  |    0 
crates/grammars/src/cpp/injections.scm                                               |    0 
crates/grammars/src/cpp/outline.scm                                                  |    0 
crates/grammars/src/cpp/overrides.scm                                                |    0 
crates/grammars/src/cpp/semantic_token_rules.json                                    |    0 
crates/grammars/src/cpp/textobjects.scm                                              |    0 
crates/grammars/src/css/brackets.scm                                                 |    0 
crates/grammars/src/css/config.toml                                                  |    0 
crates/grammars/src/css/highlights.scm                                               |    0 
crates/grammars/src/css/indents.scm                                                  |    0 
crates/grammars/src/css/injections.scm                                               |    0 
crates/grammars/src/css/outline.scm                                                  |    0 
crates/grammars/src/css/overrides.scm                                                |    0 
crates/grammars/src/css/textobjects.scm                                              |    0 
crates/grammars/src/diff/config.toml                                                 |    0 
crates/grammars/src/diff/highlights.scm                                              |    0 
crates/grammars/src/diff/injections.scm                                              |    0 
crates/grammars/src/gitcommit/config.toml                                            |    0 
crates/grammars/src/gitcommit/highlights.scm                                         |    0 
crates/grammars/src/gitcommit/injections.scm                                         |    0 
crates/grammars/src/go/brackets.scm                                                  |    0 
crates/grammars/src/go/config.toml                                                   |    0 
crates/grammars/src/go/debugger.scm                                                  |    0 
crates/grammars/src/go/highlights.scm                                                |    0 
crates/grammars/src/go/imports.scm                                                   |    0 
crates/grammars/src/go/indents.scm                                                   |    0 
crates/grammars/src/go/injections.scm                                                |    0 
crates/grammars/src/go/outline.scm                                                   |    0 
crates/grammars/src/go/overrides.scm                                                 |    0 
crates/grammars/src/go/runnables.scm                                                 |    0 
crates/grammars/src/go/semantic_token_rules.json                                     |    0 
crates/grammars/src/go/textobjects.scm                                               |    0 
crates/grammars/src/gomod/config.toml                                                |    0 
crates/grammars/src/gomod/highlights.scm                                             |    0 
crates/grammars/src/gomod/injections.scm                                             |    0 
crates/grammars/src/gomod/structure.scm                                              |    0 
crates/grammars/src/gowork/config.toml                                               |    0 
crates/grammars/src/gowork/highlights.scm                                            |    0 
crates/grammars/src/gowork/injections.scm                                            |    0 
crates/grammars/src/grammars.rs                                                      |  108 
crates/grammars/src/javascript/brackets.scm                                          |    0 
crates/grammars/src/javascript/config.toml                                           |    0 
crates/grammars/src/javascript/debugger.scm                                          |    0 
crates/grammars/src/javascript/highlights.scm                                        |    0 
crates/grammars/src/javascript/imports.scm                                           |    0 
crates/grammars/src/javascript/indents.scm                                           |    0 
crates/grammars/src/javascript/injections.scm                                        |    0 
crates/grammars/src/javascript/outline.scm                                           |    0 
crates/grammars/src/javascript/overrides.scm                                         |    0 
crates/grammars/src/javascript/runnables.scm                                         |    0 
crates/grammars/src/javascript/textobjects.scm                                       |    0 
crates/grammars/src/jsdoc/brackets.scm                                               |    0 
crates/grammars/src/jsdoc/config.toml                                                |    0 
crates/grammars/src/jsdoc/highlights.scm                                             |    0 
crates/grammars/src/json/brackets.scm                                                |    0 
crates/grammars/src/json/config.toml                                                 |    0 
crates/grammars/src/json/highlights.scm                                              |    0 
crates/grammars/src/json/indents.scm                                                 |    0 
crates/grammars/src/json/outline.scm                                                 |    0 
crates/grammars/src/json/overrides.scm                                               |    0 
crates/grammars/src/json/redactions.scm                                              |    0 
crates/grammars/src/json/runnables.scm                                               |    0 
crates/grammars/src/json/textobjects.scm                                             |    0 
crates/grammars/src/jsonc/brackets.scm                                               |    0 
crates/grammars/src/jsonc/config.toml                                                |    0 
crates/grammars/src/jsonc/highlights.scm                                             |    0 
crates/grammars/src/jsonc/indents.scm                                                |    0 
crates/grammars/src/jsonc/injections.scm                                             |    0 
crates/grammars/src/jsonc/outline.scm                                                |    0 
crates/grammars/src/jsonc/overrides.scm                                              |    0 
crates/grammars/src/jsonc/redactions.scm                                             |    0 
crates/grammars/src/jsonc/textobjects.scm                                            |    0 
crates/grammars/src/markdown-inline/config.toml                                      |    0 
crates/grammars/src/markdown-inline/highlights.scm                                   |    0 
crates/grammars/src/markdown-inline/injections.scm                                   |    0 
crates/grammars/src/markdown/brackets.scm                                            |    0 
crates/grammars/src/markdown/config.toml                                             |    0 
crates/grammars/src/markdown/highlights.scm                                          |    0 
crates/grammars/src/markdown/indents.scm                                             |    0 
crates/grammars/src/markdown/injections.scm                                          |    0 
crates/grammars/src/markdown/outline.scm                                             |    0 
crates/grammars/src/markdown/textobjects.scm                                         |    0 
crates/grammars/src/python/brackets.scm                                              |    0 
crates/grammars/src/python/config.toml                                               |    0 
crates/grammars/src/python/debugger.scm                                              |    0 
crates/grammars/src/python/highlights.scm                                            |    0 
crates/grammars/src/python/imports.scm                                               |    0 
crates/grammars/src/python/indents.scm                                               |    0 
crates/grammars/src/python/injections.scm                                            |    0 
crates/grammars/src/python/outline.scm                                               |    0 
crates/grammars/src/python/overrides.scm                                             |    0 
crates/grammars/src/python/runnables.scm                                             |    0 
crates/grammars/src/python/semantic_token_rules.json                                 |    0 
crates/grammars/src/python/textobjects.scm                                           |    0 
crates/grammars/src/regex/brackets.scm                                               |    0 
crates/grammars/src/regex/config.toml                                                |    0 
crates/grammars/src/regex/highlights.scm                                             |    0 
crates/grammars/src/rust/brackets.scm                                                |    0 
crates/grammars/src/rust/config.toml                                                 |    0 
crates/grammars/src/rust/debugger.scm                                                |    0 
crates/grammars/src/rust/highlights.scm                                              |    0 
crates/grammars/src/rust/imports.scm                                                 |    0 
crates/grammars/src/rust/indents.scm                                                 |    0 
crates/grammars/src/rust/injections.scm                                              |    0 
crates/grammars/src/rust/outline.scm                                                 |    0 
crates/grammars/src/rust/overrides.scm                                               |    0 
crates/grammars/src/rust/runnables.scm                                               |    0 
crates/grammars/src/rust/semantic_token_rules.json                                   |    0 
crates/grammars/src/rust/textobjects.scm                                             |    0 
crates/grammars/src/tsx/brackets.scm                                                 |    0 
crates/grammars/src/tsx/config.toml                                                  |    0 
crates/grammars/src/tsx/debugger.scm                                                 |    0 
crates/grammars/src/tsx/highlights.scm                                               |    0 
crates/grammars/src/tsx/imports.scm                                                  |    0 
crates/grammars/src/tsx/indents.scm                                                  |    0 
crates/grammars/src/tsx/injections.scm                                               |    0 
crates/grammars/src/tsx/outline.scm                                                  |    0 
crates/grammars/src/tsx/overrides.scm                                                |    0 
crates/grammars/src/tsx/runnables.scm                                                |    0 
crates/grammars/src/tsx/textobjects.scm                                              |    0 
crates/grammars/src/typescript/brackets.scm                                          |    0 
crates/grammars/src/typescript/config.toml                                           |    0 
crates/grammars/src/typescript/debugger.scm                                          |    0 
crates/grammars/src/typescript/highlights.scm                                        |    0 
crates/grammars/src/typescript/imports.scm                                           |    0 
crates/grammars/src/typescript/indents.scm                                           |    0 
crates/grammars/src/typescript/injections.scm                                        |    0 
crates/grammars/src/typescript/outline.scm                                           |    0 
crates/grammars/src/typescript/overrides.scm                                         |    0 
crates/grammars/src/typescript/runnables.scm                                         |    0 
crates/grammars/src/typescript/textobjects.scm                                       |    0 
crates/grammars/src/yaml/brackets.scm                                                |    0 
crates/grammars/src/yaml/config.toml                                                 |    0 
crates/grammars/src/yaml/highlights.scm                                              |    0 
crates/grammars/src/yaml/injections.scm                                              |    0 
crates/grammars/src/yaml/outline.scm                                                 |    0 
crates/grammars/src/yaml/overrides.scm                                               |    0 
crates/grammars/src/yaml/redactions.scm                                              |    0 
crates/grammars/src/yaml/textobjects.scm                                             |    0 
crates/grammars/src/zed-keybind-context/brackets.scm                                 |    0 
crates/grammars/src/zed-keybind-context/config.toml                                  |    0 
crates/grammars/src/zed-keybind-context/highlights.scm                               |    0 
crates/icons/src/icons.rs                                                            |    7 
crates/keymap_editor/src/keymap_editor.rs                                            |    4 
crates/language/Cargo.toml                                                           |    7 
crates/language/benches/highlight_map.rs                                             |  144 
crates/language/src/buffer.rs                                                        |   86 
crates/language/src/diagnostic.rs                                                    |    1 
crates/language/src/highlight_map.rs                                                 |  114 
crates/language/src/language.rs                                                      | 1312 
crates/language/src/language_registry.rs                                             |  155 
crates/language/src/language_settings.rs                                             |   16 
crates/language/src/manifest.rs                                                      |   39 
crates/language/src/syntax_map.rs                                                    |    4 
crates/language/src/syntax_map/syntax_map_tests.rs                                   |    2 
crates/language/src/toolchain.rs                                                     |  126 
crates/language_core/Cargo.toml                                                      |   29 
crates/language_core/LICENSE-GPL                                                     |    1 
crates/language_core/src/code_label.rs                                               |  122 
crates/language_core/src/diagnostic.rs                                               |   76 
crates/language_core/src/grammar.rs                                                  |  821 
crates/language_core/src/highlight_map.rs                                            |   52 
crates/language_core/src/language_config.rs                                          |  539 
crates/language_core/src/language_core.rs                                            |   39 
crates/language_core/src/language_name.rs                                            |  109 
crates/language_core/src/lsp_adapter.rs                                              |   44 
crates/language_core/src/manifest.rs                                                 |   36 
crates/language_core/src/queries.rs                                                  |   33 
crates/language_core/src/toolchain.rs                                                |  124 
crates/language_extension/src/extension_lsp_adapter.rs                               |    9 
crates/language_model/src/tool_schema.rs                                             |    7 
crates/language_onboarding/Cargo.toml                                                |    6 
crates/language_tools/src/highlights_tree_view.rs                                    |   11 
crates/languages/Cargo.toml                                                          |   38 
crates/languages/src/c.rs                                                            |    2 
crates/languages/src/cpp.rs                                                          |    4 
crates/languages/src/go.rs                                                           |    4 
crates/languages/src/lib.rs                                                          |   87 
crates/languages/src/python.rs                                                       |   20 
crates/languages/src/rust.rs                                                         |   12 
crates/markdown/Cargo.toml                                                           |    5 
crates/markdown/src/html.rs                                                          |    3 
crates/markdown/src/html/html_minifier.rs                                            |    0 
crates/markdown/src/html/html_parser.rs                                              |  883 
crates/markdown/src/html/html_rendering.rs                                           |  613 
crates/markdown/src/markdown.rs                                                      |  667 
crates/markdown/src/mermaid.rs                                                       |  614 
crates/markdown/src/parser.rs                                                        |  370 
crates/markdown_preview/Cargo.toml                                                   |   12 
crates/markdown_preview/src/markdown_elements.rs                                     |  373 
crates/markdown_preview/src/markdown_parser.rs                                       | 3320 
crates/markdown_preview/src/markdown_preview.rs                                      |    4 
crates/markdown_preview/src/markdown_preview_view.rs                                 |  625 
crates/markdown_preview/src/markdown_renderer.rs                                     | 1515 
crates/multi_buffer/Cargo.toml                                                       |    1 
crates/multi_buffer/src/multi_buffer.rs                                              |   11 
crates/onboarding/src/theme_preview.rs                                               |   14 
crates/outline_panel/src/outline_panel.rs                                            |    4 
crates/platform_title_bar/src/platform_title_bar.rs                                  |   47 
crates/project/src/git_store.rs                                                      |   17 
crates/project/src/lsp_store.rs                                                      |   39 
crates/project/src/project.rs                                                        |    1 
crates/project/src/toolchain_store.rs                                                |   22 
crates/project/tests/integration/project_tests.rs                                    |    2 
crates/project_panel/src/project_panel.rs                                            |    2 
crates/prompt_store/src/prompts.rs                                                   |    2 
crates/recent_projects/src/sidebar_recent_projects.rs                                |    7 
crates/remote_server/src/headless_project.rs                                         |    1 
crates/settings/src/vscode_import.rs                                                 |    1 
crates/settings_content/Cargo.toml                                                   |    6 
crates/settings_content/src/agent.rs                                                 |   41 
crates/settings_content/src/language.rs                                              |   23 
crates/settings_content/src/settings_content.rs                                      |   24 
crates/settings_content/src/workspace.rs                                             |    4 
crates/settings_json/Cargo.toml                                                      |    6 
crates/settings_ui/src/page_data.rs                                                  |   24 
crates/settings_ui/src/pages/edit_prediction_provider_setup.rs                       |   58 
crates/sidebar/Cargo.toml                                                            |    1 
crates/sidebar/src/project_group_builder.rs                                          |  330 
crates/sidebar/src/sidebar.rs                                                        |  775 
crates/terminal_view/src/terminal_panel.rs                                           |    2 
crates/theme/src/fallback_themes.rs                                                  |  126 
crates/theme/src/styles/syntax.rs                                                    |  126 
crates/theme/src/theme.rs                                                            |   49 
crates/title_bar/src/title_bar.rs                                                    |  114 
crates/ui/src/components/ai.rs                                                       |    2 
crates/ui/src/components/ai/ai_setting_item.rs                                       |  406 
crates/ui/src/components/icon/icon_decoration.rs                                     |   16 
crates/vim/src/helix.rs                                                              |  141 
crates/vim/src/normal.rs                                                             |   34 
crates/vim/src/state.rs                                                              |    5 
crates/vim/src/vim.rs                                                                |    4 
crates/vim/src/visual.rs                                                             |   22 
crates/workspace/Cargo.toml                                                          |    1 
crates/workspace/src/active_file_name.rs                                             |   69 
crates/workspace/src/dock.rs                                                         |   91 
crates/workspace/src/multi_workspace.rs                                              |  137 
crates/workspace/src/status_bar.rs                                                   |  156 
crates/workspace/src/workspace.rs                                                    |  245 
crates/workspace/src/workspace_settings.rs                                           |    2 
crates/zed/Cargo.toml                                                                |    2 
crates/zed/src/zed.rs                                                                |    2 
crates/zed/src/zed/edit_prediction_registry.rs                                       |    5 
docs/README.md                                                                       |    8 
docs/src/ai/edit-prediction.md                                                       |  226 
docs/src/development/glossary.md                                                     |    6 
docs/src/finding-navigating.md                                                       |    8 
docs/src/languages/elixir.md                                                         |  206 
docs/src/languages/tailwindcss.md                                                    |    2 
docs/src/migrate/webstorm.md                                                         |  133 
docs/src/performance.md                                                              |    8 
docs/src/reference/all-settings.md                                                   |    6 
tooling/xtask/src/tasks/workflows/compare_perf.rs                                    |    6 
tooling/xtask/src/tasks/workflows/extension_bump.rs                                  |  111 
tooling/xtask/src/tasks/workflows/extension_tests.rs                                 |    2 
tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs                      |   68 
tooling/xtask/src/tasks/workflows/publish_extension_cli.rs                           |   24 
tooling/xtask/src/tasks/workflows/steps.rs                                           |  162 
380 files changed, 11,233 insertions(+), 11,156 deletions(-)

Detailed changes

.github/actions/run_tests/action.yml πŸ”—

@@ -5,7 +5,7 @@ runs:
   using: "composite"
   steps:
     - name: Install nextest
-      uses: taiki-e/install-action@nextest
+      uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c # nextest
 
     - name: Install Node
       uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4

.github/actions/run_tests_windows/action.yml πŸ”—

@@ -12,7 +12,7 @@ runs:
   steps:
     - name: Install test runner
       working-directory: ${{ inputs.working-directory }}
-      uses: taiki-e/install-action@nextest
+      uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c # nextest
 
     - name: Install Node
       uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4

.github/workflows/add_commented_closed_issue_to_project.yml πŸ”—

@@ -35,7 +35,7 @@ jobs:
 
       - if: steps.is-post-close-comment.outputs.result == 'true'
         id: get-app-token
-        uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 # v2.1.4
+        uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
         with:
           app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }}
           private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }}

.github/workflows/after_release.yml πŸ”—

@@ -27,7 +27,7 @@ jobs:
     - name: after_release::rebuild_releases_page::refresh_cloud_releases
       run: curl -fX POST https://cloud.zed.dev/releases/refresh?expect_tag=${{ github.event.release.tag_name || inputs.tag_name }}
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: after_release::rebuild_releases_page::redeploy_zed_dev
@@ -110,7 +110,7 @@ jobs:
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: release::create_sentry_release

.github/workflows/assign-reviewers.yml πŸ”—

@@ -51,7 +51,7 @@ jobs:
     steps:
       - name: Generate app token
         id: app-token
-        uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf  # v2.2.1
+        uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
         with:
           app-id: ${{ vars.COORDINATOR_APP_ID }}
           private-key: ${{ secrets.COORDINATOR_APP_PRIVATE_KEY }}
@@ -60,7 +60,7 @@ jobs:
       # SECURITY: checks out the coordinator repo at ref: main, NOT the PR branch.
       # persist-credentials: false prevents the token from leaking into .git/config.
       - name: Checkout coordinator repo
-        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5  # v4.3.1
+        uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
         with:
           repository: zed-industries/codeowner-coordinator
           ref: main
@@ -69,7 +69,7 @@ jobs:
           persist-credentials: false
 
       - name: Setup Python
-        uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065  # v5.6.0
+        uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
         with:
           python-version: "3.11"
 
@@ -95,7 +95,7 @@ jobs:
 
       - name: Upload output
         if: always()
-        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
+        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
         with:
           name: assign-reviewers-output
           path: /tmp/assign-reviewers-output.txt

.github/workflows/autofix_pr.yml πŸ”—

@@ -18,7 +18,7 @@ jobs:
     runs-on: namespace-profile-16x32-ubuntu-2204
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: autofix_pr::run_autofix::checkout_pr
@@ -31,7 +31,7 @@ jobs:
         mkdir -p ./../.cargo
         cp ./.cargo/ci-config.toml ./../.cargo/config.toml
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -91,22 +91,22 @@ jobs:
     if: needs.run_autofix.outputs.has_changes == 'true'
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
-    - id: get-app-token
+    - id: generate-token
       name: steps::authenticate_as_zippy
-      uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1
+      uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859
       with:
         app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
         private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
-        token: ${{ steps.get-app-token.outputs.token }}
+        token: ${{ steps.generate-token.outputs.token }}
     - name: autofix_pr::commit_changes::checkout_pr
       run: gh pr checkout "$PR_NUMBER"
       env:
         PR_NUMBER: ${{ inputs.pr_number }}
-        GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }}
+        GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
     - name: autofix_pr::download_patch_artifact
       uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53
       with:
@@ -122,7 +122,7 @@ jobs:
         GIT_COMMITTER_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com
         GIT_AUTHOR_NAME: Zed Zippy
         GIT_AUTHOR_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com
-        GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }}
+        GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
 concurrency:
   group: ${{ github.workflow }}-${{ inputs.pr_number }}
   cancel-in-progress: true

.github/workflows/background_agent_mvp.yml πŸ”—

@@ -38,7 +38,7 @@ jobs:
 
     steps:
       - name: Checkout repository
-        uses: actions/checkout@v4
+        uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
         with:
           fetch-depth: 0
 
@@ -50,7 +50,7 @@ jobs:
           "${HOME}/.local/bin/droid" --version
 
       - name: Setup Python
-        uses: actions/setup-python@v5
+        uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
         with:
           python-version: "3.12"
 

.github/workflows/bump_collab_staging.yml πŸ”—

@@ -11,7 +11,7 @@ jobs:
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
       - name: Checkout repository
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+        uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
         with:
           fetch-depth: 0
 

.github/workflows/bump_patch_version.yml πŸ”—

@@ -13,18 +13,18 @@ jobs:
     if: github.repository_owner == 'zed-industries'
     runs-on: namespace-profile-16x32-ubuntu-2204
     steps:
-    - id: get-app-token
+    - id: generate-token
       name: steps::authenticate_as_zippy
-      uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1
+      uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859
       with:
         app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
         private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
         ref: ${{ inputs.branch }}
-        token: ${{ steps.get-app-token.outputs.token }}
+        token: ${{ steps.generate-token.outputs.token }}
     - name: bump_patch_version::run_bump_patch_version::bump_patch_version
       run: |
         channel="$(cat crates/zed/RELEASE_CHANNEL)"
@@ -51,7 +51,7 @@ jobs:
         GIT_COMMITTER_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com
         GIT_AUTHOR_NAME: Zed Zippy
         GIT_AUTHOR_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com
-        GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }}
+        GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
 concurrency:
   group: ${{ github.workflow }}-${{ inputs.branch }}
   cancel-in-progress: true

.github/workflows/catch_blank_issues.yml πŸ”—

@@ -16,7 +16,7 @@ jobs:
     timeout-minutes: 5
     steps:
       - id: get-app-token
-        uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 # v2.1.4
+        uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
         with:
           app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }}
           private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }}

.github/workflows/cherry_pick.yml πŸ”—

@@ -26,12 +26,12 @@ jobs:
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
-    - id: get-app-token
+    - id: generate-token
       name: steps::authenticate_as_zippy
-      uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1
+      uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859
       with:
         app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
         private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
@@ -43,7 +43,7 @@ jobs:
         CHANNEL: ${{ inputs.channel }}
         GIT_COMMITTER_NAME: Zed Zippy
         GIT_COMMITTER_EMAIL: hi@zed.dev
-        GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }}
+        GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
 defaults:
   run:
     shell: bash -euxo pipefail {0}

.github/workflows/comment_on_potential_duplicate_issues.yml πŸ”—

@@ -27,14 +27,14 @@ jobs:
 
     steps:
       - name: Checkout repository
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+        uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
         with:
           sparse-checkout: script/github-check-new-issue-for-duplicates.py
           sparse-checkout-cone-mode: false
 
       - name: Get github app token
         id: get-app-token
-        uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 # v1.11.7
+        uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
         with:
           app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }}
           private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }}

.github/workflows/community_champion_auto_labeler.yml πŸ”—

@@ -12,7 +12,7 @@ jobs:
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
       - name: Check if author is a community champion and apply label
-        uses: actions/github-script@v7
+        uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
         env:
           COMMUNITY_CHAMPIONS: |
             0x2CA

.github/workflows/community_update_all_top_ranking_issues.yml πŸ”—

@@ -10,7 +10,7 @@ jobs:
     runs-on: namespace-profile-2x4-ubuntu-2404
     if: github.repository == 'zed-industries/zed'
     steps:
-      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+      - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
       - name: Set up uv
         uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3
         with:

.github/workflows/community_update_weekly_top_ranking_issues.yml πŸ”—

@@ -10,7 +10,7 @@ jobs:
     runs-on: namespace-profile-2x4-ubuntu-2404
     if: github.repository == 'zed-industries/zed'
     steps:
-      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+      - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
       - name: Set up uv
         uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3
         with:

.github/workflows/compare_perf.yml πŸ”—

@@ -21,7 +21,7 @@ jobs:
     runs-on: namespace-profile-16x32-ubuntu-2204
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_cargo_config
@@ -33,7 +33,7 @@ jobs:
     - name: steps::download_wasi_sdk
       run: ./script/download-wasi-sdk
     - name: compare_perf::run_perf::install_hyperfine
-      uses: taiki-e/install-action@hyperfine
+      uses: taiki-e/install-action@b4f2d5cb8597b15997c8ede873eb6185efc5f0ad
     - name: steps::git_checkout
       run: git fetch origin "$REF_NAME" && git checkout "$REF_NAME"
       env:

.github/workflows/congrats.yml πŸ”—

@@ -13,7 +13,7 @@ jobs:
     steps:
       - name: Get PR info and check if author is external
         id: check
-        uses: actions/github-script@v7
+        uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
         with:
           github-token: ${{ secrets.CONGRATSBOT_GITHUB_TOKEN }}
           script: |
@@ -29,6 +29,13 @@ jobs:
             }
 
             const mergedPR = prs.find(pr => pr.merged_at !== null) || prs[0];
+
+            if (mergedPR.user.type === "Bot") {
+              // They are a good bot, but not good enough to be congratulated
+              core.setOutput('should_congratulate', 'false');
+              return;
+            }
+
             const prAuthor = mergedPR.user.login;
 
             try {
@@ -50,7 +57,7 @@ jobs:
   congrats:
     needs: check-author
     if: needs.check-author.outputs.should_congratulate == 'true'
-    uses: withastro/automation/.github/workflows/congratsbot.yml@main
+    uses: withastro/automation/.github/workflows/congratsbot.yml@a5bd0c5748c4d56e687cdd558064f9ee8adfb1f2 # main
     with:
       EMOJIS: πŸŽ‰,🎊,πŸ§‘β€πŸš€,πŸ₯³,πŸ™Œ,πŸš€,πŸ¦€,πŸ”₯,🚒
     secrets:

.github/workflows/danger.yml πŸ”—

@@ -16,7 +16,7 @@ jobs:
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_pnpm

.github/workflows/deploy_cloudflare.yml πŸ”—

@@ -13,7 +13,7 @@ jobs:
 
     steps:
       - name: Checkout repo
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+        uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
         with:
           clean: false
 

.github/workflows/deploy_collab.yml πŸ”—

@@ -17,7 +17,7 @@ jobs:
       CXX: clang++
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
         fetch-depth: 0
@@ -26,7 +26,7 @@ jobs:
         mkdir -p ./../.cargo
         cp ./.cargo/ci-config.toml ./../.cargo/config.toml
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -48,7 +48,7 @@ jobs:
       CXX: clang++
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
         fetch-depth: 0
@@ -57,7 +57,7 @@ jobs:
         mkdir -p ./../.cargo
         cp ./.cargo/ci-config.toml ./../.cargo/config.toml
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -66,7 +66,7 @@ jobs:
     - name: steps::download_wasi_sdk
       run: ./script/download-wasi-sdk
     - name: steps::cargo_install_nextest
-      uses: taiki-e/install-action@nextest
+      uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c
     - name: steps::clear_target_dir_if_large
       run: ./script/clear-target-dir-if-larger-than 250
     - name: deploy_collab::tests::run_collab_tests
@@ -93,7 +93,7 @@ jobs:
     - name: deploy_collab::publish::sign_into_registry
       run: doctl registry login
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: deploy_collab::publish::build_docker_image
@@ -113,7 +113,7 @@ jobs:
     runs-on: namespace-profile-16x32-ubuntu-2204
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: deploy_collab::deploy::install_doctl

.github/workflows/docs_suggestions.yml πŸ”—

@@ -64,7 +64,7 @@ jobs:
 
     steps:
       - name: Checkout repository
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+        uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
         with:
           fetch-depth: 0
           token: ${{ secrets.GITHUB_TOKEN }}
@@ -296,7 +296,7 @@ jobs:
 
     steps:
       - name: Checkout repository
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+        uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
         with:
           fetch-depth: 0
           ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.ref || '' }}

.github/workflows/extension_auto_bump.yml πŸ”—

@@ -17,7 +17,7 @@ jobs:
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
         fetch-depth: 2

.github/workflows/extension_bump.yml πŸ”—

@@ -5,7 +5,7 @@ env:
   CARGO_TERM_COLOR: always
   RUST_BACKTRACE: '1'
   CARGO_INCREMENTAL: '0'
-  ZED_EXTENSION_CLI_SHA: 03d8e9aee95ea6117d75a48bcac2e19241f6e667
+  ZED_EXTENSION_CLI_SHA: 1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7
 on:
   workflow_call:
     inputs:
@@ -34,7 +34,7 @@ jobs:
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
         fetch-depth: 0
@@ -74,17 +74,17 @@ jobs:
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
     - id: generate-token
-      name: extension_bump::generate_token
-      uses: actions/create-github-app-token@v2
+      name: steps::generate_token
+      uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859
       with:
         app-id: ${{ secrets.app-id }}
         private-key: ${{ secrets.app-secret }}
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -138,7 +138,7 @@ jobs:
         BUMP_TYPE: ${{ inputs.bump-type }}
         WORKING_DIR: ${{ inputs.working-directory }}
     - name: extension_bump::create_pull_request
-      uses: peter-evans/create-pull-request@v7
+      uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725
       with:
         title: ${{ steps.bump-version.outputs.title }}
         body: ${{ steps.bump-version.outputs.body }}
@@ -162,13 +162,13 @@ jobs:
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
     - id: generate-token
-      name: extension_bump::generate_token
-      uses: actions/create-github-app-token@v2
+      name: steps::generate_token
+      uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859
       with:
         app-id: ${{ secrets.app-id }}
         private-key: ${{ secrets.app-secret }}
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - id: determine-tag
@@ -187,7 +187,7 @@ jobs:
         CURRENT_VERSION: ${{ needs.check_version_changed.outputs.current_version }}
         WORKING_DIR: ${{ inputs.working-directory }}
     - name: extension_bump::create_version_tag
-      uses: actions/github-script@v7
+      uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b
       with:
         script: |-
           github.rest.git.createRef({
@@ -212,15 +212,15 @@ jobs:
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
     - id: generate-token
-      name: extension_bump::generate_token
-      uses: actions/create-github-app-token@v2
+      name: steps::generate_token
+      uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859
       with:
         app-id: ${{ secrets.app-id }}
         private-key: ${{ secrets.app-secret }}
         owner: zed-industries
         repositories: extensions
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - id: get-extension-id
@@ -239,7 +239,7 @@ jobs:
       env:
         COMMITTER_TOKEN: ${{ steps.generate-token.outputs.token }}
     - name: extension_bump::enable_automerge_if_staff
-      uses: actions/github-script@v7
+      uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b
       with:
         github-token: ${{ steps.generate-token.outputs.token }}
         script: |

.github/workflows/extension_tests.yml πŸ”—

@@ -5,7 +5,7 @@ env:
   CARGO_TERM_COLOR: always
   RUST_BACKTRACE: '1'
   CARGO_INCREMENTAL: '0'
-  ZED_EXTENSION_CLI_SHA: 03d8e9aee95ea6117d75a48bcac2e19241f6e667
+  ZED_EXTENSION_CLI_SHA: 1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7
   RUSTUP_TOOLCHAIN: stable
   CARGO_BUILD_TARGET: wasm32-wasip2
 on:
@@ -21,7 +21,7 @@ jobs:
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
         fetch-depth: ${{ github.ref == 'refs/heads/main' && 2 || 350 }}
@@ -73,11 +73,11 @@ jobs:
     runs-on: namespace-profile-8x32-ubuntu-2404
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -97,7 +97,7 @@ jobs:
       env:
         PACKAGE_NAME: ${{ steps.get-package-name.outputs.package_name }}
     - name: steps::cargo_install_nextest
-      uses: taiki-e/install-action@nextest
+      uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c
     - name: extension_tests::run_nextest
       run: 'cargo nextest run -p "$PACKAGE_NAME" --no-fail-fast --no-tests=warn --target "$(rustc -vV | sed -n ''s|host: ||p'')"'
       env:
@@ -115,7 +115,7 @@ jobs:
     runs-on: namespace-profile-8x32-ubuntu-2404
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
         fetch-depth: 0
@@ -131,7 +131,7 @@ jobs:
         wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension" -O "$GITHUB_WORKSPACE/zed-extension"
         chmod +x "$GITHUB_WORKSPACE/zed-extension"
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup

.github/workflows/extension_workflow_rollout.yml πŸ”—

@@ -20,7 +20,7 @@ jobs:
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
     - name: checkout_zed_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
         fetch-depth: 0
@@ -57,7 +57,7 @@ jobs:
         PREV_COMMIT: ${{ steps.prev-tag.outputs.prev_commit }}
     - id: list-repos
       name: extension_workflow_rollout::fetch_extension_repos::get_repositories
-      uses: actions/github-script@v7
+      uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b
       with:
         script: |
           const repos = await github.paginate(github.rest.repos.listForOrg, {
@@ -81,7 +81,7 @@ jobs:
           return filteredRepos;
         result-encoding: json
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -114,8 +114,8 @@ jobs:
       max-parallel: 10
     steps:
     - id: generate-token
-      name: extension_bump::generate_token
-      uses: actions/create-github-app-token@v2
+      name: steps::generate_token
+      uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859
       with:
         app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
         private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
@@ -125,7 +125,7 @@ jobs:
         permission-contents: write
         permission-workflows: write
     - name: checkout_extension_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
         path: extension
@@ -173,7 +173,7 @@ jobs:
         echo "sha_short=$(echo "$GITHUB_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT"
     - id: create-pr
       name: extension_workflow_rollout::rollout_workflows_to_extension::create_pull_request
-      uses: peter-evans/create-pull-request@v7
+      uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725
       with:
         path: extension
         title: Update CI workflows to `${{ steps.short-sha.outputs.sha_short }}`
@@ -207,14 +207,14 @@ jobs:
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
     - id: generate-token
-      name: extension_bump::generate_token
-      uses: actions/create-github-app-token@v2
+      name: steps::generate_token
+      uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859
       with:
         app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
         private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
         permission-contents: write
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
         fetch-depth: 0

.github/workflows/good_first_issue_notifier.yml πŸ”—

@@ -11,7 +11,7 @@ jobs:
 
     steps:
       - name: Checkout repository
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+        uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
 
       - name: Prepare Discord message
         id: prepare-message

.github/workflows/pr_labeler.yml πŸ”—

@@ -17,7 +17,7 @@ jobs:
     timeout-minutes: 5
     steps:
       - id: get-app-token
-        uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 # v2.1.4
+        uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
         with:
           app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }}
           private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }}

.github/workflows/publish_extension_cli.yml πŸ”—

@@ -11,14 +11,14 @@ on:
 jobs:
   publish_job:
     if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
-    runs-on: namespace-profile-2x4-ubuntu-2404
+    runs-on: namespace-profile-16x32-ubuntu-2204
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -38,17 +38,17 @@ jobs:
     runs-on: namespace-profile-8x16-ubuntu-2204
     steps:
     - id: generate-token
-      name: extension_bump::generate_token
-      uses: actions/create-github-app-token@v2
+      name: steps::generate_token
+      uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859
       with:
         app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
         private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -63,7 +63,7 @@ jobs:
     - name: publish_extension_cli::update_sha_in_zed::regenerate_workflows
       run: cargo xtask workflows
     - name: publish_extension_cli::create_pull_request_zed
-      uses: peter-evans/create-pull-request@v7
+      uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725
       with:
         title: 'extension_ci: Bump extension CLI version to `${{ steps.short-sha.outputs.sha_short }}`'
         body: |
@@ -87,8 +87,8 @@ jobs:
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
     - id: generate-token
-      name: extension_bump::generate_token
-      uses: actions/create-github-app-token@v2
+      name: steps::generate_token
+      uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859
       with:
         app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
         private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
@@ -108,7 +108,7 @@ jobs:
         sed -i "s/ZED_EXTENSION_CLI_SHA: [a-f0-9]*/ZED_EXTENSION_CLI_SHA: $GITHUB_SHA/" \
             .github/workflows/ci.yml
     - name: publish_extension_cli::create_pull_request_extensions
-      uses: peter-evans/create-pull-request@v7
+      uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725
       with:
         title: Bump extension CLI version to `${{ steps.short-sha.outputs.sha_short }}`
         body: |

.github/workflows/randomized_tests.yml πŸ”—

@@ -28,7 +28,7 @@ jobs:
           node-version: "18"
 
       - name: Checkout repo
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+        uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
         with:
           clean: false
 

.github/workflows/release.yml πŸ”—

@@ -14,7 +14,7 @@ jobs:
     runs-on: namespace-profile-mac-large
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_cargo_config
@@ -22,7 +22,7 @@ jobs:
         mkdir -p ./../.cargo
         cp ./.cargo/ci-config.toml ./../.cargo/config.toml
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -31,7 +31,7 @@ jobs:
       with:
         node-version: '20'
     - name: steps::cargo_install_nextest
-      uses: taiki-e/install-action@nextest
+      uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c
     - name: steps::clear_target_dir_if_large
       run: ./script/clear-target-dir-if-larger-than 300
     - name: steps::setup_sccache
@@ -58,7 +58,7 @@ jobs:
       CXX: clang++
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_cargo_config
@@ -66,7 +66,7 @@ jobs:
         mkdir -p ./../.cargo
         cp ./.cargo/ci-config.toml ./../.cargo/config.toml
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -79,7 +79,7 @@ jobs:
       with:
         node-version: '20'
     - name: steps::cargo_install_nextest
-      uses: taiki-e/install-action@nextest
+      uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c
     - name: steps::clear_target_dir_if_large
       run: ./script/clear-target-dir-if-larger-than 250
     - name: steps::setup_sccache
@@ -111,7 +111,7 @@ jobs:
     runs-on: self-32vcpu-windows-2022
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_cargo_config
@@ -151,7 +151,7 @@ jobs:
     runs-on: namespace-profile-mac-large
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_cargo_config
@@ -159,7 +159,7 @@ jobs:
         mkdir -p ./../.cargo
         cp ./.cargo/ci-config.toml ./../.cargo/config.toml
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -183,7 +183,7 @@ jobs:
       CXX: clang++
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_cargo_config
@@ -191,7 +191,7 @@ jobs:
         mkdir -p ./../.cargo
         cp ./.cargo/ci-config.toml ./../.cargo/config.toml
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -216,7 +216,7 @@ jobs:
     runs-on: self-32vcpu-windows-2022
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_cargo_config
@@ -244,7 +244,7 @@ jobs:
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: run_tests::check_scripts::run_shellcheck
@@ -257,7 +257,7 @@ jobs:
       env:
         ACTIONLINT_BIN: ${{ steps.get_actionlint.outputs.executable }}
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -275,7 +275,7 @@ jobs:
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
         fetch-depth: 25
@@ -305,7 +305,7 @@ jobs:
       CXX: clang++-18
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_sentry
@@ -345,7 +345,7 @@ jobs:
       CXX: clang++-18
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_sentry
@@ -388,7 +388,7 @@ jobs:
       APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_node
@@ -433,7 +433,7 @@ jobs:
       APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_node
@@ -482,7 +482,7 @@ jobs:
       TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_sentry
@@ -527,7 +527,7 @@ jobs:
       TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_sentry
@@ -617,16 +617,16 @@ jobs:
     if: startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
-    - id: get-app-token
+    - id: generate-token
       name: steps::authenticate_as_zippy
-      uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1
+      uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859
       with:
         app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
         private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
     - name: gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false
       run: gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false
       env:
-        GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }}
+        GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
   push_release_update_notification:
     needs:
     - create_draft_release

.github/workflows/release_nightly.yml πŸ”—

@@ -16,7 +16,7 @@ jobs:
     runs-on: namespace-profile-mac-large
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
         fetch-depth: 0
@@ -30,7 +30,7 @@ jobs:
     runs-on: self-32vcpu-windows-2022
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_cargo_config
@@ -70,7 +70,7 @@ jobs:
     runs-on: self-32vcpu-windows-2022
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_cargo_config
@@ -107,7 +107,7 @@ jobs:
       CXX: clang++-18
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: run_bundling::set_release_channel_to_nightly
@@ -153,7 +153,7 @@ jobs:
       CXX: clang++-18
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: run_bundling::set_release_channel_to_nightly
@@ -202,7 +202,7 @@ jobs:
       APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: run_bundling::set_release_channel_to_nightly
@@ -253,7 +253,7 @@ jobs:
       APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: run_bundling::set_release_channel_to_nightly
@@ -308,7 +308,7 @@ jobs:
       TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: run_bundling::set_release_channel_to_nightly
@@ -361,7 +361,7 @@ jobs:
       TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: run_bundling::set_release_channel_to_nightly
@@ -406,11 +406,11 @@ jobs:
       GIT_LFS_SKIP_SMUDGE: '1'
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::cache_nix_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: nix
     - name: nix_build::build_nix::install_nix
@@ -440,11 +440,11 @@ jobs:
       GIT_LFS_SKIP_SMUDGE: '1'
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::cache_nix_store_macos
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         path: ~/nix-cache
     - name: nix_build::build_nix::install_nix
@@ -488,7 +488,7 @@ jobs:
     runs-on: namespace-profile-4x8-ubuntu-2204
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
         fetch-depth: 0

.github/workflows/run_agent_evals.yml πŸ”—

@@ -24,11 +24,11 @@ jobs:
     runs-on: namespace-profile-16x32-ubuntu-2204
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup

.github/workflows/run_bundling.yml πŸ”—

@@ -23,7 +23,7 @@ jobs:
       CXX: clang++-18
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_sentry
@@ -62,7 +62,7 @@ jobs:
       CXX: clang++-18
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_sentry
@@ -104,7 +104,7 @@ jobs:
       APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_node
@@ -148,7 +148,7 @@ jobs:
       APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_node
@@ -196,7 +196,7 @@ jobs:
       TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_sentry
@@ -240,7 +240,7 @@ jobs:
       TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_sentry
@@ -274,11 +274,11 @@ jobs:
       GIT_LFS_SKIP_SMUDGE: '1'
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::cache_nix_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: nix
     - name: nix_build::build_nix::install_nix
@@ -306,11 +306,11 @@ jobs:
       GIT_LFS_SKIP_SMUDGE: '1'
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::cache_nix_store_macos
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         path: ~/nix-cache
     - name: nix_build::build_nix::install_nix

.github/workflows/run_cron_unit_evals.yml πŸ”—

@@ -21,7 +21,7 @@ jobs:
       fail-fast: false
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_cargo_config
@@ -29,7 +29,7 @@ jobs:
         mkdir -p ./../.cargo
         cp ./.cargo/ci-config.toml ./../.cargo/config.toml
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -38,7 +38,7 @@ jobs:
     - name: steps::download_wasi_sdk
       run: ./script/download-wasi-sdk
     - name: steps::cargo_install_nextest
-      uses: taiki-e/install-action@nextest
+      uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c
     - name: steps::clear_target_dir_if_large
       run: ./script/clear-target-dir-if-larger-than 250
     - name: steps::setup_sccache

.github/workflows/run_tests.yml πŸ”—

@@ -19,7 +19,7 @@ jobs:
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
         fetch-depth: ${{ github.ref == 'refs/heads/main' && 2 || 350 }}
@@ -124,11 +124,11 @@ jobs:
     runs-on: namespace-profile-4x8-ubuntu-2204
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -171,7 +171,7 @@ jobs:
     runs-on: self-32vcpu-windows-2022
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_cargo_config
@@ -204,7 +204,7 @@ jobs:
       CXX: clang++
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_cargo_config
@@ -212,7 +212,7 @@ jobs:
         mkdir -p ./../.cargo
         cp ./.cargo/ci-config.toml ./../.cargo/config.toml
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -239,7 +239,7 @@ jobs:
     runs-on: namespace-profile-mac-large
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_cargo_config
@@ -247,7 +247,7 @@ jobs:
         mkdir -p ./../.cargo
         cp ./.cargo/ci-config.toml ./../.cargo/config.toml
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -270,7 +270,7 @@ jobs:
     runs-on: namespace-profile-mac-large
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_cargo_config
@@ -278,7 +278,7 @@ jobs:
         mkdir -p ./../.cargo
         cp ./.cargo/ci-config.toml ./../.cargo/config.toml
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -303,7 +303,7 @@ jobs:
     runs-on: self-32vcpu-windows-2022
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_cargo_config
@@ -348,7 +348,7 @@ jobs:
       CXX: clang++
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_cargo_config
@@ -356,7 +356,7 @@ jobs:
         mkdir -p ./../.cargo
         cp ./.cargo/ci-config.toml ./../.cargo/config.toml
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -369,7 +369,7 @@ jobs:
       with:
         node-version: '20'
     - name: steps::cargo_install_nextest
-      uses: taiki-e/install-action@nextest
+      uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c
     - name: steps::clear_target_dir_if_large
       run: ./script/clear-target-dir-if-larger-than 250
     - name: steps::setup_sccache
@@ -403,7 +403,7 @@ jobs:
     runs-on: namespace-profile-mac-large
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_cargo_config
@@ -411,7 +411,7 @@ jobs:
         mkdir -p ./../.cargo
         cp ./.cargo/ci-config.toml ./../.cargo/config.toml
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -420,7 +420,7 @@ jobs:
       with:
         node-version: '20'
     - name: steps::cargo_install_nextest
-      uses: taiki-e/install-action@nextest
+      uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c
     - name: steps::clear_target_dir_if_large
       run: ./script/clear-target-dir-if-larger-than 300
     - name: steps::setup_sccache
@@ -449,11 +449,11 @@ jobs:
       CXX: clang++
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -493,7 +493,7 @@ jobs:
       CXX: clang++
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_cargo_config
@@ -501,7 +501,7 @@ jobs:
         mkdir -p ./../.cargo
         cp ./.cargo/ci-config.toml ./../.cargo/config.toml
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -534,7 +534,7 @@ jobs:
     runs-on: namespace-profile-8x16-ubuntu-2204
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_cargo_config
@@ -542,7 +542,7 @@ jobs:
         mkdir -p ./../.cargo
         cp ./.cargo/ci-config.toml ./../.cargo/config.toml
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -576,11 +576,11 @@ jobs:
       CXX: clang++
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -611,7 +611,7 @@ jobs:
       CXX: clang++
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_cargo_config
@@ -619,7 +619,7 @@ jobs:
         mkdir -p ./../.cargo
         cp ./.cargo/ci-config.toml ./../.cargo/config.toml
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -657,11 +657,11 @@ jobs:
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -676,7 +676,7 @@ jobs:
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: run_tests::check_scripts::run_shellcheck
@@ -689,7 +689,7 @@ jobs:
       env:
         ACTIONLINT_BIN: ${{ steps.get_actionlint.outputs.executable }}
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -714,7 +714,7 @@ jobs:
       GIT_COMMITTER_EMAIL: ci@zed.dev
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
         fetch-depth: 0

.github/workflows/run_unit_evals.yml πŸ”—

@@ -24,7 +24,7 @@ jobs:
     runs-on: namespace-profile-16x32-ubuntu-2204
     steps:
     - name: steps::checkout_repo
-      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
       with:
         clean: false
     - name: steps::setup_cargo_config
@@ -32,7 +32,7 @@ jobs:
         mkdir -p ./../.cargo
         cp ./.cargo/ci-config.toml ./../.cargo/config.toml
     - name: steps::cache_rust_dependencies_namespace
-      uses: namespacelabs/nscloud-cache-action@v1
+      uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9
       with:
         cache: rust
         path: ~/.rustup
@@ -41,7 +41,7 @@ jobs:
     - name: steps::download_wasi_sdk
       run: ./script/download-wasi-sdk
     - name: steps::cargo_install_nextest
-      uses: taiki-e/install-action@nextest
+      uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c
     - name: steps::clear_target_dir_if_large
       run: ./script/clear-target-dir-if-larger-than 250
     - name: steps::setup_sccache

.github/workflows/track_duplicate_bot_effectiveness.yml πŸ”—

@@ -22,14 +22,14 @@ jobs:
     timeout-minutes: 5
     steps:
       - name: Checkout repository
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+        uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
         with:
           sparse-checkout: script/github-track-duplicate-bot-effectiveness.py
           sparse-checkout-cone-mode: false
 
       - name: Get github app token
         id: get-app-token
-        uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 # v1.11.7
+        uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
         with:
           app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }}
           private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }}
@@ -61,14 +61,14 @@ jobs:
     timeout-minutes: 10
     steps:
       - name: Checkout repository
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+        uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
         with:
           sparse-checkout: script/github-track-duplicate-bot-effectiveness.py
           sparse-checkout-cone-mode: false
 
       - name: Get github app token
         id: get-app-token
-        uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 # v1.11.7
+        uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
         with:
           app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }}
           private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }}

.github/workflows/update_duplicate_magnets.yml πŸ”—

@@ -10,7 +10,7 @@ jobs:
     runs-on: ubuntu-latest
     if: github.repository == 'zed-industries/zed'
     steps:
-      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+      - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
 
       - name: Set up Python
         uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5

Cargo.lock πŸ”—

@@ -511,21 +511,6 @@ dependencies = [
  "equator",
 ]
 
-[[package]]
-name = "alloc-no-stdlib"
-version = "2.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
-
-[[package]]
-name = "alloc-stdlib"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
-dependencies = [
- "alloc-no-stdlib",
-]
-
 [[package]]
 name = "allocator-api2"
 version = "0.2.21"
@@ -2247,27 +2232,6 @@ dependencies = [
  "workspace",
 ]
 
-[[package]]
-name = "brotli"
-version = "8.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
-dependencies = [
- "alloc-no-stdlib",
- "alloc-stdlib",
- "brotli-decompressor",
-]
-
-[[package]]
-name = "brotli-decompressor"
-version = "5.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
-dependencies = [
- "alloc-no-stdlib",
- "alloc-stdlib",
-]
-
 [[package]]
 name = "brush-parser"
 version = "0.3.0"
@@ -5244,7 +5208,6 @@ version = "0.1.0"
 dependencies = [
  "ai_onboarding",
  "anyhow",
- "brotli",
  "buffer_diff",
  "client",
  "clock",
@@ -7921,6 +7884,35 @@ dependencies = [
  "zed-scap",
 ]
 
+[[package]]
+name = "grammars"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "language_core",
+ "rust-embed",
+ "toml 0.8.23",
+ "tree-sitter",
+ "tree-sitter-bash",
+ "tree-sitter-c",
+ "tree-sitter-cpp",
+ "tree-sitter-css",
+ "tree-sitter-diff",
+ "tree-sitter-gitcommit",
+ "tree-sitter-go",
+ "tree-sitter-gomod",
+ "tree-sitter-gowork",
+ "tree-sitter-jsdoc",
+ "tree-sitter-json",
+ "tree-sitter-md",
+ "tree-sitter-python",
+ "tree-sitter-regex",
+ "tree-sitter-rust",
+ "tree-sitter-typescript",
+ "tree-sitter-yaml",
+ "util",
+]
+
 [[package]]
 name = "grid"
 version = "0.18.0"
@@ -9368,6 +9360,7 @@ dependencies = [
  "async-trait",
  "clock",
  "collections",
+ "criterion",
  "ctor",
  "diffy",
  "ec4rs",
@@ -9381,6 +9374,7 @@ dependencies = [
  "imara-diff",
  "indoc",
  "itertools 0.14.0",
+ "language_core",
  "log",
  "lsp",
  "parking_lot",
@@ -9389,7 +9383,6 @@ dependencies = [
  "rand 0.9.2",
  "regex",
  "rpc",
- "schemars",
  "semver",
  "serde",
  "serde_json",
@@ -9424,6 +9417,25 @@ dependencies = [
  "ztracing",
 ]
 
+[[package]]
+name = "language_core"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "collections",
+ "gpui",
+ "log",
+ "lsp",
+ "parking_lot",
+ "regex",
+ "schemars",
+ "serde",
+ "serde_json",
+ "toml 0.8.23",
+ "tree-sitter",
+ "util",
+]
+
 [[package]]
 name = "language_extension"
 version = "0.1.0"
@@ -9616,9 +9628,11 @@ dependencies = [
  "async-trait",
  "chrono",
  "collections",
+ "fs",
  "futures 0.3.31",
  "globset",
  "gpui",
+ "grammars",
  "http_client",
  "itertools 0.14.0",
  "json_schema_store",
@@ -9638,7 +9652,6 @@ dependencies = [
  "project",
  "regex",
  "rope",
- "rust-embed",
  "semver",
  "serde",
  "serde_json",
@@ -9650,25 +9663,16 @@ dependencies = [
  "task",
  "terminal",
  "theme",
- "toml 0.8.23",
  "tree-sitter",
  "tree-sitter-bash",
  "tree-sitter-c",
  "tree-sitter-cpp",
  "tree-sitter-css",
- "tree-sitter-diff",
  "tree-sitter-gitcommit",
  "tree-sitter-go",
- "tree-sitter-gomod",
- "tree-sitter-gowork",
- "tree-sitter-jsdoc",
- "tree-sitter-json",
- "tree-sitter-md",
  "tree-sitter-python",
- "tree-sitter-regex",
  "tree-sitter-rust",
  "tree-sitter-typescript",
- "tree-sitter-yaml",
  "unindent",
  "url",
  "util",
@@ -10261,6 +10265,7 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
 name = "markdown"
 version = "0.1.0"
 dependencies = [
+ "anyhow",
  "assets",
  "base64 0.22.1",
  "collections",
@@ -10269,13 +10274,17 @@ dependencies = [
  "futures 0.3.31",
  "gpui",
  "gpui_platform",
+ "html5ever 0.27.0",
  "language",
  "languages",
  "linkify",
  "log",
+ "markup5ever_rcdom",
+ "mermaid-rs-renderer",
  "node_runtime",
  "pulldown-cmark 0.13.0",
  "settings",
+ "stacksafe",
  "sum_tree",
  "theme",
  "ui",
@@ -10287,21 +10296,13 @@ name = "markdown_preview"
 version = "0.1.0"
 dependencies = [
  "anyhow",
- "async-recursion",
- "collections",
  "editor",
  "gpui",
- "html5ever 0.27.0",
  "language",
- "linkify",
  "log",
  "markdown",
- "markup5ever_rcdom",
- "mermaid-rs-renderer",
- "pretty_assertions",
- "pulldown-cmark 0.13.0",
  "settings",
- "stacksafe",
+ "tempfile",
  "theme",
  "ui",
  "urlencoding",
@@ -10788,6 +10789,7 @@ dependencies = [
  "theme",
  "tracing",
  "tree-sitter",
+ "unicode-segmentation",
  "util",
  "zlog",
  "ztracing",
@@ -15975,6 +15977,7 @@ dependencies = [
  "action_log",
  "agent",
  "agent-client-protocol",
+ "agent_settings",
  "agent_ui",
  "anyhow",
  "assistant_text_thread",
@@ -21513,6 +21516,7 @@ dependencies = [
 name = "workspace"
 version = "0.1.0"
 dependencies = [
+ "agent_settings",
  "any_vec",
  "anyhow",
  "async-recursion",
@@ -21971,7 +21975,7 @@ dependencies = [
 
 [[package]]
 name = "zed"
-version = "0.230.0"
+version = "0.231.0"
 dependencies = [
  "acp_thread",
  "acp_tools",

Cargo.toml πŸ”—

@@ -87,6 +87,7 @@ members = [
     "crates/git_ui",
     "crates/go_to_line",
     "crates/google_ai",
+    "crates/grammars",
     "crates/gpui",
     "crates/gpui_linux",
     "crates/gpui_macos",
@@ -108,6 +109,7 @@ members = [
     "crates/json_schema_store",
     "crates/keymap_editor",
     "crates/language",
+    "crates/language_core",
     "crates/language_extension",
     "crates/language_model",
     "crates/language_models",
@@ -330,6 +332,7 @@ git_hosting_providers = { path = "crates/git_hosting_providers" }
 git_ui = { path = "crates/git_ui" }
 go_to_line = { path = "crates/go_to_line" }
 google_ai = { path = "crates/google_ai" }
+grammars = { path = "crates/grammars" }
 gpui = { path = "crates/gpui", default-features = false }
 gpui_linux = { path = "crates/gpui_linux", default-features = false }
 gpui_macos = { path = "crates/gpui_macos", default-features = false }
@@ -354,6 +357,7 @@ journal = { path = "crates/journal" }
 json_schema_store = { path = "crates/json_schema_store" }
 keymap_editor = { path = "crates/keymap_editor" }
 language = { path = "crates/language" }
+language_core = { path = "crates/language_core" }
 language_extension = { path = "crates/language_extension" }
 language_model = { path = "crates/language_model" }
 language_models = { path = "crates/language_models" }

assets/icons/threads_sidebar_right_closed.svg πŸ”—

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect opacity="0.1" width="5" height="12" rx="2" transform="matrix(-1 0 0 1 14 2)" fill="#C6CAD0"/>
+<path d="M9 2V14" stroke="#C6CAD0" stroke-width="1.2"/>
+<rect x="2" y="2" width="12" height="12" rx="1.5" stroke="#C6CAD0" stroke-width="1.2"/>
+</svg>

assets/icons/threads_sidebar_right_open.svg πŸ”—

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect opacity="0.8" width="5" height="12" rx="2" transform="matrix(-1 0 0 1 14 2)" fill="#C6CAD0"/>
+<path d="M9 2V14" stroke="#C6CAD0" stroke-width="1.2"/>
+<rect x="2" y="2" width="12" height="12" rx="1.5" stroke="#C6CAD0" stroke-width="1.2"/>
+</svg>

assets/keymaps/default-linux.json πŸ”—

@@ -698,12 +698,18 @@
       "left": "menu::SelectParent",
       "right": "menu::SelectChild",
       "enter": "menu::Confirm",
-      "space": "menu::Confirm",
       "ctrl-f": "agents_sidebar::FocusSidebarFilter",
       "ctrl-g": "agents_sidebar::ToggleArchive",
       "shift-backspace": "agent::RemoveSelectedThread",
     },
   },
+  {
+    "context": "ThreadsSidebar && not_searching",
+    "use_key_equivalents": true,
+    "bindings": {
+      "space": "menu::Confirm",
+    },
+  },
   {
     "context": "Workspace && debugger_running",
     "bindings": {
@@ -1071,6 +1077,7 @@
       "alt-up": "collab_panel::MoveChannelUp",
       "alt-down": "collab_panel::MoveChannelDown",
       "alt-enter": "collab_panel::OpenSelectedChannelNotes",
+      "shift-enter": "collab_panel::ToggleSelectedChannelFavorite",
     },
   },
   {

assets/keymaps/default-macos.json πŸ”—

@@ -764,12 +764,18 @@
       "left": "menu::SelectParent",
       "right": "menu::SelectChild",
       "enter": "menu::Confirm",
-      "space": "menu::Confirm",
       "cmd-f": "agents_sidebar::FocusSidebarFilter",
       "cmd-g": "agents_sidebar::ToggleArchive",
       "shift-backspace": "agent::RemoveSelectedThread",
     },
   },
+  {
+    "context": "ThreadsSidebar && not_searching",
+    "use_key_equivalents": true,
+    "bindings": {
+      "space": "menu::Confirm",
+    },
+  },
   {
     "context": "Workspace && debugger_running",
     "use_key_equivalents": true,
@@ -1132,6 +1138,7 @@
       "alt-up": "collab_panel::MoveChannelUp",
       "alt-down": "collab_panel::MoveChannelDown",
       "alt-enter": "collab_panel::OpenSelectedChannelNotes",
+      "shift-enter": "collab_panel::ToggleSelectedChannelFavorite",
     },
   },
   {

assets/keymaps/default-windows.json πŸ”—

@@ -700,12 +700,18 @@
       "left": "menu::SelectParent",
       "right": "menu::SelectChild",
       "enter": "menu::Confirm",
-      "space": "menu::Confirm",
       "ctrl-f": "agents_sidebar::FocusSidebarFilter",
       "ctrl-g": "agents_sidebar::ToggleArchive",
       "shift-backspace": "agent::RemoveSelectedThread",
     },
   },
+  {
+    "context": "ThreadsSidebar && not_searching",
+    "use_key_equivalents": true,
+    "bindings": {
+      "space": "menu::Confirm",
+    },
+  },
   {
     "context": "ApplicationMenu",
     "use_key_equivalents": true,
@@ -1076,6 +1082,7 @@
       "alt-up": "collab_panel::MoveChannelUp",
       "alt-down": "collab_panel::MoveChannelDown",
       "alt-enter": "collab_panel::OpenSelectedChannelNotes",
+      "shift-enter": "collab_panel::ToggleSelectedChannelFavorite",
     },
   },
   {

assets/keymaps/vim.json πŸ”—

@@ -337,6 +337,8 @@
       "shift-j": "vim::JoinLines",
       "i": "vim::InsertBefore",
       "a": "vim::InsertAfter",
+      "o": "vim::InsertLineBelow",
+      "shift-o": "vim::InsertLineAbove",
       "p": "vim::Paste",
       "u": "vim::Undo",
       "r": "vim::PushReplace",

assets/settings/default.json πŸ”—

@@ -943,6 +943,8 @@
     "button": true,
     // Where to dock the agent panel. Can be 'left', 'right' or 'bottom'.
     "dock": "right",
+    // Where to position the sidebar. Can be 'left', 'right', or 'follow_agent'.
+    "sidebar_side": "follow_agent",
     // Default width when the agent panel is docked to the left or right.
     "default_width": 640,
     // Default height when the agent panel is docked to the bottom.
@@ -1585,13 +1587,6 @@
       "model": "codestral-latest",
       "max_tokens": 150,
     },
-    "sweep": {
-      // When enabled, Sweep will not store edit prediction inputs or outputs.
-      // When disabled, Sweep may collect data including buffer contents,
-      // diagnostics, file paths, repository names, and generated predictions
-      // to improve the service.
-      "privacy_mode": false,
-    },
     "ollama": {
       "api_url": "http://localhost:11434",
       "model": "qwen2.5-coder:7b-base",
@@ -1622,6 +1617,8 @@
   "status_bar": {
     // Whether to show the status bar.
     "experimental.show": true,
+    // Whether to show the name of the active file in the status bar.
+    "show_active_file": false,
     // Whether to show the active language button in the status bar.
     "active_language_button": true,
     // Whether to show the cursor position button in the status bar.

crates/acp_thread/src/acp_thread.rs πŸ”—

@@ -160,6 +160,7 @@ pub enum AgentThreadEntry {
     UserMessage(UserMessage),
     AssistantMessage(AssistantMessage),
     ToolCall(ToolCall),
+    CompletedPlan(Vec<PlanEntry>),
 }
 
 impl AgentThreadEntry {
@@ -168,6 +169,7 @@ impl AgentThreadEntry {
             Self::UserMessage(message) => message.indented,
             Self::AssistantMessage(message) => message.indented,
             Self::ToolCall(_) => false,
+            Self::CompletedPlan(_) => false,
         }
     }
 
@@ -176,6 +178,14 @@ impl AgentThreadEntry {
             Self::UserMessage(message) => message.to_markdown(cx),
             Self::AssistantMessage(message) => message.to_markdown(cx),
             Self::ToolCall(tool_call) => tool_call.to_markdown(cx),
+            Self::CompletedPlan(entries) => {
+                let mut md = String::from("## Plan\n\n");
+                for entry in entries {
+                    let source = entry.content.read(cx).source().to_string();
+                    md.push_str(&format!("- [x] {}\n", source));
+                }
+                md
+            }
         }
     }
 
@@ -1298,7 +1308,9 @@ impl AcpThread {
                     status: ToolCallStatus::WaitingForConfirmation { .. },
                     ..
                 }) => return true,
-                AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {}
+                AgentThreadEntry::ToolCall(_)
+                | AgentThreadEntry::AssistantMessage(_)
+                | AgentThreadEntry::CompletedPlan(_) => {}
             }
         }
         false
@@ -1320,7 +1332,9 @@ impl AcpThread {
                 ) if call.diffs().next().is_some() => {
                     return true;
                 }
-                AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {}
+                AgentThreadEntry::ToolCall(_)
+                | AgentThreadEntry::AssistantMessage(_)
+                | AgentThreadEntry::CompletedPlan(_) => {}
             }
         }
 
@@ -1337,7 +1351,9 @@ impl AcpThread {
                 }) => {
                     return true;
                 }
-                AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {}
+                AgentThreadEntry::ToolCall(_)
+                | AgentThreadEntry::AssistantMessage(_)
+                | AgentThreadEntry::CompletedPlan(_) => {}
             }
         }
 
@@ -1348,7 +1364,9 @@ impl AcpThread {
         for entry in self.entries.iter().rev() {
             match entry {
                 AgentThreadEntry::UserMessage(..) => return false,
-                AgentThreadEntry::AssistantMessage(..) => continue,
+                AgentThreadEntry::AssistantMessage(..) | AgentThreadEntry::CompletedPlan(..) => {
+                    continue;
+                }
                 AgentThreadEntry::ToolCall(..) => return true,
             }
         }
@@ -2065,6 +2083,13 @@ impl AcpThread {
         cx.notify();
     }
 
+    pub fn snapshot_completed_plan(&mut self, cx: &mut Context<Self>) {
+        if !self.plan.is_empty() && self.plan.stats().pending == 0 {
+            let completed_entries = std::mem::take(&mut self.plan.entries);
+            self.push_entry(AgentThreadEntry::CompletedPlan(completed_entries), cx);
+        }
+    }
+
     fn clear_completed_plan_entries(&mut self, cx: &mut Context<Self>) {
         self.plan
             .entries
@@ -2072,6 +2097,11 @@ impl AcpThread {
         cx.notify();
     }
 
+    pub fn clear_plan(&mut self, cx: &mut Context<Self>) {
+        self.plan.entries.clear();
+        cx.notify();
+    }
+
     #[cfg(any(test, feature = "test-support"))]
     pub fn send_raw(
         &mut self,
@@ -2218,6 +2248,10 @@ impl AcpThread {
                             this.mark_pending_tools_as_canceled();
                         }
 
+                        if !canceled {
+                            this.snapshot_completed_plan(cx);
+                        }
+
                         // Handle refusal - distinguish between user prompt and tool call refusals
                         if let acp::StopReason::Refusal = r.stop_reason {
                             this.had_error = true;
@@ -3177,9 +3211,27 @@ mod tests {
             );
         });
 
-        // Wait for the printf command to execute and produce output
-        // Use real time since parking is enabled
-        cx.executor().timer(Duration::from_millis(500)).await;
+        // Poll until the printf command produces output, rather than using a
+        // fixed sleep which is flaky on loaded machines.
+        let deadline = std::time::Instant::now() + Duration::from_secs(10);
+        loop {
+            let has_output = thread.read_with(cx, |thread, cx| {
+                let term = thread
+                    .terminals
+                    .get(&terminal_id)
+                    .expect("terminal not found");
+                let content = term.read(cx).inner().read(cx).get_content();
+                content.contains("output_before_kill")
+            });
+            if has_output {
+                break;
+            }
+            assert!(
+                std::time::Instant::now() < deadline,
+                "Timed out waiting for printf output to appear in terminal",
+            );
+            cx.executor().timer(Duration::from_millis(50)).await;
+        }
 
         // Get the acp_thread Terminal and kill it
         let wait_for_exit = thread.update(cx, |thread, cx| {

crates/acp_tools/src/acp_tools.rs πŸ”—

@@ -291,7 +291,6 @@ impl AcpTools {
         v_flex()
             .id(index)
             .group("message")
-            .cursor_pointer()
             .font_buffer(cx)
             .w_full()
             .py_3()
@@ -303,27 +302,29 @@ impl AcpTools {
             .border_color(colors.border)
             .border_b_1()
             .hover(|this| this.bg(colors.element_background.opacity(0.5)))
-            .on_click(cx.listener(move |this, _, _, cx| {
-                if this.expanded.contains(&index) {
-                    this.expanded.remove(&index);
-                } else {
-                    this.expanded.insert(index);
-                    let Some(connection) = &mut this.watched_connection else {
-                        return;
-                    };
-                    let Some(message) = connection.messages.get_mut(index) else {
-                        return;
-                    };
-                    message.expanded(this.project.read(cx).languages().clone(), cx);
-                    connection.list_state.scroll_to_reveal_item(index);
-                }
-                cx.notify()
-            }))
             .child(
                 h_flex()
+                    .id(("acp-log-message-header", index))
                     .w_full()
                     .gap_2()
                     .flex_shrink_0()
+                    .cursor_pointer()
+                    .on_click(cx.listener(move |this, _, _, cx| {
+                        if this.expanded.contains(&index) {
+                            this.expanded.remove(&index);
+                        } else {
+                            this.expanded.insert(index);
+                            let Some(connection) = &mut this.watched_connection else {
+                                return;
+                            };
+                            let Some(message) = connection.messages.get_mut(index) else {
+                                return;
+                            };
+                            message.expanded(this.project.read(cx).languages().clone(), cx);
+                            connection.list_state.scroll_to_reveal_item(index);
+                        }
+                        cx.notify()
+                    }))
                     .child(match message.direction {
                         acp::StreamMessageDirection::Incoming => Icon::new(IconName::ArrowDown)
                             .color(Color::Error)

crates/agent/src/agent.rs πŸ”—

@@ -942,6 +942,9 @@ impl NativeAgent {
                 NativeAgentConnection::handle_thread_events(events, acp_thread.downgrade(), cx)
             })
             .await?;
+            acp_thread.update(cx, |thread, cx| {
+                thread.snapshot_completed_plan(cx);
+            });
             Ok(acp_thread)
         })
     }

crates/agent/src/edit_agent/streaming_fuzzy_matcher.rs πŸ”—

@@ -72,6 +72,18 @@ impl StreamingFuzzyMatcher {
     pub fn finish(&mut self) -> Vec<Range<usize>> {
         // Process any remaining incomplete line
         if !self.incomplete_line.is_empty() {
+            if self.matches.len() == 1 {
+                let range = &mut self.matches[0];
+                if range.end < self.snapshot.len()
+                    && self
+                        .snapshot
+                        .contains_str_at(range.end + 1, &self.incomplete_line)
+                {
+                    range.end += 1 + self.incomplete_line.len();
+                    return self.matches.clone();
+                }
+            }
+
             self.query_lines.push(self.incomplete_line.clone());
             self.incomplete_line.clear();
             self.matches = self.resolve_location_fuzzy();
@@ -722,6 +734,54 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    fn test_prefix_of_last_line_resolves_to_correct_range() {
+        let text = indoc! {r#"
+            fn on_query_change(&mut self, cx: &mut Context<Self>) {
+                self.filter(cx);
+            }
+
+
+
+            fn render_search(&self, cx: &mut Context<Self>) -> Div {
+                div()
+            }
+        "#};
+
+        let buffer = TextBuffer::new(
+            ReplicaId::LOCAL,
+            BufferId::new(1).unwrap(),
+            text.to_string(),
+        );
+        let snapshot = buffer.snapshot();
+
+        // Query with a partial last line.
+        let query = "}\n\n\n\nfn render_search";
+
+        let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone());
+        matcher.push(query, None);
+        let matches = matcher.finish();
+
+        // The match should include the line containing "fn render_search".
+        let matched_text = matches
+            .first()
+            .map(|range| snapshot.text_for_range(range.clone()).collect::<String>());
+
+        assert!(
+            matches.len() == 1,
+            "Expected exactly one match, got {}: {:?}",
+            matches.len(),
+            matched_text,
+        );
+
+        let matched_text = matched_text.unwrap();
+        pretty_assertions::assert_eq!(
+            matched_text,
+            "}\n\n\n\nfn render_search",
+            "Match should include the render_search line",
+        );
+    }
+
     #[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);

crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-09.diff πŸ”—

@@ -0,0 +1,20 @@
+@@ -5,7 +5,7 @@
+ use futures::AsyncWriteExt;
+ use gpui::SharedString;
+ use serde::{Deserialize, Serialize};
+-use std::process::Stdio;
++use std::process::{Output, Stdio};
+ use std::{ops::Range, path::Path};
+ use text::Rope;
+ use time::OffsetDateTime;
+@@ -94,6 +94,10 @@
+
+     let output = child.output().await.context("reading git blame output")?;
+
++    handle_command_output(output)
++}
++
++fn handle_command_output(output: Output) -> Result<String> {
+     if !output.status.success() {
+         let stderr = String::from_utf8_lossy(&output.stderr);
+         let trimmed = stderr.trim();

crates/agent/src/tools/evals/streaming_edit_file.rs πŸ”—

@@ -808,6 +808,8 @@ fn eval_extract_handle_command_output() {
         include_str!("fixtures/extract_handle_command_output/possible-05.diff"),
         include_str!("fixtures/extract_handle_command_output/possible-06.diff"),
         include_str!("fixtures/extract_handle_command_output/possible-07.diff"),
+        include_str!("fixtures/extract_handle_command_output/possible-08.diff"),
+        include_str!("fixtures/extract_handle_command_output/possible-09.diff"),
     ];
 
     eval_utils::eval(100, 0.95, eval_utils::NoProcessor, move || {

crates/agent/src/tools/streaming_edit_file_tool.rs πŸ”—

@@ -111,12 +111,13 @@ pub enum StreamingEditFileMode {
 }
 
 /// A single edit operation that replaces old text with new text
+/// Properly escape all text fields as valid JSON strings.
+/// Remember to escape special characters like newlines (`\n`) and quotes (`"`) in JSON strings.
 #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
 pub struct Edit {
     /// The exact text to find in the file. This will be matched using fuzzy matching
     /// to handle minor differences in whitespace or formatting.
     ///
-    /// Always include complete lines. Do not start or end mid-line.
     /// Be minimal with replacements:
     /// - For unique lines, include only those lines
     /// - For non-unique lines, include enough context to identify them
@@ -3916,6 +3917,58 @@ mod tests {
         assert_eq!(new_text, "new_content");
     }
 
+    #[gpui::test]
+    async fn test_streaming_edit_partial_last_line(cx: &mut TestAppContext) {
+        let file_content = indoc::indoc! {r#"
+            fn on_query_change(&mut self, cx: &mut Context<Self>) {
+                self.filter(cx);
+            }
+
+
+
+            fn render_search(&self, cx: &mut Context<Self>) -> Div {
+                div()
+            }
+        "#}
+        .to_string();
+
+        let (tool, _project, _action_log, _fs, _thread) =
+            setup_test(cx, json!({"file.rs": file_content})).await;
+
+        // The model sends old_text with a PARTIAL last line.
+        let old_text = "}\n\n\n\nfn render_search";
+        let new_text = "}\n\nfn render_search";
+
+        let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
+        let (event_stream, _receiver) = ToolCallEventStream::test();
+        let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
+
+        sender.send_final(json!({
+            "display_description": "Remove extra blank lines",
+            "path": "root/file.rs",
+            "mode": "edit",
+            "edits": [{"old_text": old_text, "new_text": new_text}]
+        }));
+
+        let result = task.await;
+        let StreamingEditFileToolOutput::Success {
+            new_text: final_text,
+            ..
+        } = result.unwrap()
+        else {
+            panic!("expected success");
+        };
+
+        // The edit should reduce 3 blank lines to 1 blank line before
+        // fn render_search, without duplicating the function signature.
+        let expected = file_content.replace("}\n\n\n\nfn render_search", "}\n\nfn render_search");
+        pretty_assertions::assert_eq!(
+            final_text,
+            expected,
+            "Edit should only remove blank lines before render_search"
+        );
+    }
+
     #[gpui::test]
     async fn test_streaming_reject_created_file_deletes_it(cx: &mut TestAppContext) {
         let (tool, _project, action_log, fs, _thread) = setup_test(cx, json!({"dir": {}})).await;

crates/agent_settings/src/agent_settings.rs πŸ”—

@@ -12,7 +12,8 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{
     DefaultAgentView, DockPosition, LanguageModelParameters, LanguageModelSelection,
-    NewThreadLocation, NotifyWhenAgentWaiting, RegisterSetting, Settings, ToolPermissionMode,
+    NewThreadLocation, NotifyWhenAgentWaiting, RegisterSetting, Settings, SidebarDockPosition,
+    SidebarSide, ToolPermissionMode,
 };
 
 pub use crate::agent_profile::*;
@@ -26,6 +27,7 @@ pub struct AgentSettings {
     pub enabled: bool,
     pub button: bool,
     pub dock: DockPosition,
+    pub sidebar_side: SidebarDockPosition,
     pub default_width: Pixels,
     pub default_height: Pixels,
     pub default_model: Option<LanguageModelSelection>,
@@ -77,6 +79,17 @@ impl AgentSettings {
         return None;
     }
 
+    pub fn sidebar_side(&self) -> SidebarSide {
+        match self.sidebar_side {
+            SidebarDockPosition::Left => SidebarSide::Left,
+            SidebarDockPosition::Right => SidebarSide::Right,
+            SidebarDockPosition::FollowAgent => match self.dock {
+                DockPosition::Right => SidebarSide::Right,
+                _ => SidebarSide::Left,
+            },
+        }
+    }
+
     pub fn set_message_editor_max_lines(&self) -> usize {
         self.message_editor_min_lines * 2
     }
@@ -407,6 +420,7 @@ impl Settings for AgentSettings {
             enabled: agent.enabled.unwrap(),
             button: agent.button.unwrap(),
             dock: agent.dock.unwrap(),
+            sidebar_side: agent.sidebar_side.unwrap(),
             default_width: px(agent.default_width.unwrap()),
             default_height: px(agent.default_height.unwrap()),
             default_model: Some(agent.default_model.unwrap()),

crates/agent_ui/src/agent_configuration.rs πŸ”—

@@ -4,7 +4,7 @@ mod configure_context_server_tools_modal;
 mod manage_profiles_modal;
 mod tool_picker;
 
-use std::{ops::Range, sync::Arc};
+use std::{ops::Range, rc::Rc, sync::Arc};
 
 use agent::ContextServerRegistry;
 use anyhow::Result;
@@ -33,9 +33,9 @@ use project::{
 };
 use settings::{Settings, SettingsStore, update_settings_file};
 use ui::{
-    ButtonStyle, Chip, CommonAnimationExt, ContextMenu, ContextMenuEntry, Disclosure, Divider,
-    DividerColor, ElevationIndex, Indicator, LabelSize, PopoverMenu, Switch, Tooltip,
-    WithScrollbar, prelude::*,
+    AiSettingItem, AiSettingItemSource, AiSettingItemStatus, ButtonStyle, Chip, ContextMenu,
+    ContextMenuEntry, Disclosure, Divider, DividerColor, ElevationIndex, LabelSize, PopoverMenu,
+    Switch, Tooltip, WithScrollbar, prelude::*,
 };
 use util::ResultExt as _;
 use workspace::{Workspace, create_and_open_local_file};
@@ -45,29 +45,32 @@ pub(crate) use configure_context_server_modal::ConfigureContextServerModal;
 pub(crate) use configure_context_server_tools_modal::ConfigureContextServerToolsModal;
 pub(crate) use manage_profiles_modal::ManageProfilesModal;
 
-use crate::agent_configuration::add_llm_provider_modal::{
-    AddLlmProviderModal, LlmCompatibleProvider,
+use crate::{
+    Agent,
+    agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider},
+    agent_connection_store::{AgentConnectionStatus, AgentConnectionStore},
 };
 
 pub struct AgentConfiguration {
     fs: Arc<dyn Fs>,
     language_registry: Arc<LanguageRegistry>,
     agent_server_store: Entity<AgentServerStore>,
+    agent_connection_store: Entity<AgentConnectionStore>,
     workspace: WeakEntity<Workspace>,
     focus_handle: FocusHandle,
     configuration_views_by_provider: HashMap<LanguageModelProviderId, AnyView>,
     context_server_store: Entity<ContextServerStore>,
     expanded_provider_configurations: HashMap<LanguageModelProviderId, bool>,
     context_server_registry: Entity<ContextServerRegistry>,
-    _registry_subscription: Subscription,
+    _subscriptions: Vec<Subscription>,
     scroll_handle: ScrollHandle,
-    _check_for_gemini: Task<()>,
 }
 
 impl AgentConfiguration {
     pub fn new(
         fs: Arc<dyn Fs>,
         agent_server_store: Entity<AgentServerStore>,
+        agent_connection_store: Entity<AgentConnectionStore>,
         context_server_store: Entity<ContextServerStore>,
         context_server_registry: Entity<ContextServerRegistry>,
         language_registry: Arc<LanguageRegistry>,
@@ -77,25 +80,27 @@ impl AgentConfiguration {
     ) -> 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);
+        let subscriptions = vec![
+            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();
+                    language_model::Event::RemovedProvider(provider_id) => {
+                        this.remove_provider_configuration_view(provider_id);
+                    }
+                    _ => {}
+                },
+            ),
+            cx.subscribe(&agent_server_store, |_, _, _, cx| cx.notify()),
+            cx.observe(&agent_connection_store, |_, _, cx| cx.notify()),
+            cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify()),
+        ];
 
         let mut this = Self {
             fs,
@@ -104,13 +109,14 @@ impl AgentConfiguration {
             focus_handle,
             configuration_views_by_provider: HashMap::default(),
             agent_server_store,
+            agent_connection_store,
             context_server_store,
             expanded_provider_configurations: HashMap::default(),
             context_server_registry,
-            _registry_subscription: registry_subscription,
+            _subscriptions: subscriptions,
             scroll_handle: ScrollHandle::new(),
-            _check_for_gemini: Task::ready(()),
         };
+
         this.build_provider_configuration_views(window, cx);
         this
     }
@@ -636,6 +642,22 @@ impl AgentConfiguration {
             )
         });
 
+        let display_name = if provided_by_extension {
+            resolve_extension_for_context_server(&context_server_id, cx)
+                .map(|(_, manifest)| {
+                    let name = manifest.name.as_str();
+                    let stripped = name
+                        .strip_suffix(" MCP Server")
+                        .or_else(|| name.strip_suffix(" MCP"))
+                        .or_else(|| name.strip_suffix(" Context Server"))
+                        .unwrap_or(name);
+                    SharedString::from(stripped.to_string())
+                })
+                .unwrap_or_else(|| item_id.clone())
+        } else {
+            item_id.clone()
+        };
+
         let error = if let ContextServerStatus::Error(error) = server_status.clone() {
             Some(error)
         } else {
@@ -651,57 +673,19 @@ impl AgentConfiguration {
             .tools_for_server(&context_server_id)
             .count();
 
-        let (source_icon, source_tooltip) = if provided_by_extension {
-            (
-                IconName::ZedSrcExtension,
-                "This MCP server was installed from an extension.",
-            )
+        let source = if provided_by_extension {
+            AiSettingItemSource::Extension
         } else {
-            (
-                IconName::ZedSrcCustom,
-                "This custom MCP server was installed directly.",
-            )
+            AiSettingItemSource::Custom
         };
 
-        let (status_indicator, tooltip_text) = match server_status {
-            ContextServerStatus::Starting => (
-                Icon::new(IconName::LoadCircle)
-                    .size(IconSize::XSmall)
-                    .color(Color::Accent)
-                    .with_keyed_rotate_animation(
-                        SharedString::from(format!("{}-starting", context_server_id.0)),
-                        3,
-                    )
-                    .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.",
-            ),
-            ContextServerStatus::AuthRequired => (
-                Indicator::dot().color(Color::Warning).into_any_element(),
-                "Authentication required.",
-            ),
-            ContextServerStatus::Authenticating => (
-                Icon::new(IconName::LoadCircle)
-                    .size(IconSize::XSmall)
-                    .color(Color::Accent)
-                    .with_keyed_rotate_animation(
-                        SharedString::from(format!("{}-authenticating", context_server_id.0)),
-                        3,
-                    )
-                    .into_any_element(),
-                "Waiting for authorization...",
-            ),
+        let status = match server_status {
+            ContextServerStatus::Starting => AiSettingItemStatus::Starting,
+            ContextServerStatus::Running => AiSettingItemStatus::Running,
+            ContextServerStatus::Error(_) => AiSettingItemStatus::Error,
+            ContextServerStatus::Stopped => AiSettingItemStatus::Stopped,
+            ContextServerStatus::AuthRequired => AiSettingItemStatus::AuthRequired,
+            ContextServerStatus::Authenticating => AiSettingItemStatus::Authenticating,
         };
 
         let is_remote = server_configuration
@@ -845,232 +829,165 @@ impl AgentConfiguration {
         let feedback_base_container =
             || h_flex().py_1().min_w_0().w_full().gap_1().justify_between();
 
-        v_flex()
-            .min_w_0()
-            .id(item_id.clone())
-            .child(
-                h_flex()
-                    .min_w_0()
-                    .w_full()
-                    .justify_between()
+        let details: Option<AnyElement> = if let Some(error) = error {
+            Some(
+                feedback_base_container()
                     .child(
                         h_flex()
-                            .flex_1()
+                            .pr_4()
                             .min_w_0()
+                            .w_full()
+                            .gap_2()
                             .child(
-                                h_flex()
-                                    .id(format!("tooltip-{}", item_id))
-                                    .h_full()
-                                    .w_3()
-                                    .mr_2()
-                                    .justify_center()
-                                    .tooltip(Tooltip::text(tooltip_text))
-                                    .child(status_indicator),
-                            )
-                            .child(Label::new(item_id).flex_shrink_0().truncate())
-                            .child(
-                                div()
-                                    .id("extension-source")
-                                    .min_w_0()
-                                    .mt_0p5()
-                                    .mx_1()
-                                    .tooltip(Tooltip::text(source_tooltip))
-                                    .child(
-                                        Icon::new(source_icon)
-                                            .size(IconSize::Small)
-                                            .color(Color::Muted),
-                                    ),
+                                Icon::new(IconName::XCircle)
+                                    .size(IconSize::XSmall)
+                                    .color(Color::Error),
                             )
-                            .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(div().min_w_0().flex_1().child(
+                                Label::new(error).color(Color::Muted).size(LabelSize::Small),
+                            )),
                     )
-                    .child(
-                        h_flex()
-                            .gap_0p5()
-                            .flex_none()
-                            .child(context_server_configuration_menu)
-                            .child(
-                            Switch::new("context-server-switch", is_running.into())
+                    .when(should_show_logout_button, |this| {
+                        this.child(
+                            Button::new("error-logout-server", "Log Out")
+                                .style(ButtonStyle::Outlined)
+                                .label_size(LabelSize::Small)
                                 .on_click({
-                                    let context_server_manager = self.context_server_store.clone();
-                                    let fs = self.fs.clone();
+                                    let context_server_store = context_server_store.clone();
                                     let context_server_id = context_server_id.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(fs.clone(), cx, {
-                                            let context_server_id = context_server_id.clone();
-
-                                            move |settings, _| {
-                                                settings
-                                                    .project
-                                                    .context_servers
-                                                    .entry(context_server_id.0)
-                                                    .or_insert_with(|| {
-                                                        settings::ContextServerSettingsContent::Extension {
-                                                            enabled: is_enabled,
-                                                            remote: false,
-                                                            settings: serde_json::json!({}),
-                                                        }
-                                                    })
-                                                    .set_enabled(is_enabled);
-                                            }
+                                    move |_event, _window, cx| {
+                                        context_server_store.update(cx, |store, cx| {
+                                            store.logout_server(&context_server_id, cx).log_err();
                                         });
                                     }
                                 }),
-                        ),
-                    ),
+                        )
+                    })
+                    .into_any_element(),
             )
-            .map(|parent| {
-                if let Some(error) = error {
-                    return parent
-                        .child(
-                            feedback_base_container()
-                                .child(
-                                    h_flex()
-                                        .pr_4()
-                                        .min_w_0()
-                                        .w_full()
-                                        .gap_2()
-                                        .child(
-                                            Icon::new(IconName::XCircle)
-                                                .size(IconSize::XSmall)
-                                                .color(Color::Error),
-                                        )
-                                        .child(
-                                            div().min_w_0().flex_1().child(
-                                                Label::new(error)
-                                                    .color(Color::Muted)
-                                                    .size(LabelSize::Small),
-                                            ),
-                                        ),
-                                )
-                                .when(should_show_logout_button, |this| {
-                                    this.child(
-                                        Button::new("error-logout-server", "Log Out")
-                                            .style(ButtonStyle::Outlined)
-                                            .label_size(LabelSize::Small)
-                                            .on_click({
-                                                let context_server_store =
-                                                    context_server_store.clone();
-                                                let context_server_id =
-                                                    context_server_id.clone();
-                                                move |_event, _window, cx| {
-                                                    context_server_store.update(
-                                                        cx,
-                                                        |store, cx| {
-                                                            store
-                                                                .logout_server(
-                                                                    &context_server_id,
-                                                                    cx,
-                                                                )
-                                                                .log_err();
-                                                        },
-                                                    );
-                                                }
-                                            }),
-                                    )
-                                }),
-                        );
-                }
-                if auth_required {
-                    return parent.child(
-                        feedback_base_container()
-                            .child(
-                                h_flex()
-                                    .pr_4()
-                                    .min_w_0()
-                                    .w_full()
-                                    .gap_2()
-                                    .child(
-                                        Icon::new(IconName::Info)
-                                            .size(IconSize::XSmall)
-                                            .color(Color::Muted),
-                                    )
-                                    .child(
-                                        Label::new("Authenticate to connect this server")
-                                            .color(Color::Muted)
-                                            .size(LabelSize::Small),
-                                    ),
-                            )
-                            .child(
-                                Button::new("error-logout-server", "Authenticate")
-                                    .style(ButtonStyle::Outlined)
-                                    .label_size(LabelSize::Small)
-                                    .on_click({
-                                        let context_server_store = context_server_store.clone();
-                                        let context_server_id = context_server_id.clone();
-                                        move |_event, _window, cx| {
-                                            context_server_store.update(cx, |store, cx| {
-                                                store
-                                                    .authenticate_server(&context_server_id, cx)
-                                                    .log_err();
-                                            });
-                                        }
-                                    }),
-                            ),
-                    );
-                }
-                if authenticating {
-                    return parent.child(
+        } else if auth_required {
+            Some(
+                feedback_base_container()
+                    .child(
                         h_flex()
-                            .mt_1()
                             .pr_4()
                             .min_w_0()
                             .w_full()
                             .gap_2()
                             .child(
-                                div().size_3().flex_shrink_0(), // Alignment Div
+                                Icon::new(IconName::Info)
+                                    .size(IconSize::XSmall)
+                                    .color(Color::Muted),
                             )
                             .child(
-                                Label::new("Authenticating…")
+                                Label::new("Authenticate to connect this server")
                                     .color(Color::Muted)
                                     .size(LabelSize::Small),
                             ),
+                    )
+                    .child(
+                        Button::new("error-logout-server", "Authenticate")
+                            .style(ButtonStyle::Outlined)
+                            .label_size(LabelSize::Small)
+                            .on_click({
+                                let context_server_id = context_server_id.clone();
+                                move |_event, _window, cx| {
+                                    context_server_store.update(cx, |store, cx| {
+                                        store.authenticate_server(&context_server_id, cx).log_err();
+                                    });
+                                }
+                            }),
+                    )
+                    .into_any_element(),
+            )
+        } else if authenticating {
+            Some(
+                h_flex()
+                    .mt_1()
+                    .pr_4()
+                    .min_w_0()
+                    .w_full()
+                    .gap_2()
+                    .child(div().size_3().flex_shrink_0())
+                    .child(
+                        Label::new("Authenticating…")
+                            .color(Color::Muted)
+                            .size(LabelSize::Small),
+                    )
+                    .into_any_element(),
+            )
+        } else {
+            None
+        };
 
-                    );
-                }
-                parent
+        let tool_label = if is_running {
+            Some(if tool_count == 1 {
+                SharedString::from("1 tool")
+            } else {
+                SharedString::from(format!("{} tools", tool_count))
             })
+        } else {
+            None
+        };
+
+        AiSettingItem::new(item_id, display_name, status, source)
+            .action(context_server_configuration_menu)
+            .action(
+                Switch::new("context-server-switch", is_running.into()).on_click({
+                    let context_server_manager = self.context_server_store.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(fs.clone(), cx, {
+                            let context_server_id = context_server_id.clone();
+
+                            move |settings, _| {
+                                settings
+                                    .project
+                                    .context_servers
+                                    .entry(context_server_id.0)
+                                    .or_insert_with(|| {
+                                        settings::ContextServerSettingsContent::Extension {
+                                            enabled: is_enabled,
+                                            remote: false,
+                                            settings: serde_json::json!({}),
+                                        }
+                                    })
+                                    .set_enabled(is_enabled);
+                            }
+                        });
+                    }
+                }),
+            )
+            .when_some(tool_label, |this, label| this.detail_label(label))
+            .when_some(details, |this, details| this.details(details))
     }
 
     fn render_agent_servers_section(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
         let agent_server_store = self.agent_server_store.read(cx);
 
-        let user_defined_agents = agent_server_store
+        let agents = agent_server_store
             .external_agents()
             .cloned()
             .collect::<Vec<_>>();
 
-        let user_defined_agents: Vec<_> = user_defined_agents
+        let agents: Vec<_> = agents
             .into_iter()
             .map(|name| {
                 let icon = if let Some(icon_path) = agent_server_store.agent_icon(&name) {
@@ -1159,24 +1076,31 @@ impl AgentConfiguration {
                         "All agents connected through the Agent Client Protocol.",
                         add_agent_popover.into_any_element(),
                     ))
-                    .child(v_flex().p_4().pt_0().gap_2().map(|mut parent| {
-                        let mut first = true;
-                        for (name, icon, display_name, source) in user_defined_agents {
-                            if !first {
-                                parent = parent
-                                    .child(Divider::horizontal().color(DividerColor::BorderFaded));
-                            }
-                            first = false;
-                            parent = parent.child(self.render_agent_server(
-                                icon,
-                                name,
-                                display_name,
-                                source,
-                                cx,
-                            ));
-                        }
-                        parent
-                    })),
+                    .child(
+                        v_flex()
+                            .p_4()
+                            .pt_0()
+                            .gap_2()
+                            .children(Itertools::intersperse_with(
+                                agents
+                                    .into_iter()
+                                    .map(|(name, icon, display_name, source)| {
+                                        self.render_agent_server(
+                                            icon,
+                                            name,
+                                            display_name,
+                                            source,
+                                            cx,
+                                        )
+                                        .into_any_element()
+                                    }),
+                                || {
+                                    Divider::horizontal()
+                                        .color(DividerColor::BorderFaded)
+                                        .into_any_element()
+                                },
+                            )),
+                    ),
             )
     }
 
@@ -1200,27 +1124,46 @@ impl AgentConfiguration {
                 .color(Color::Muted),
         };
 
-        let source_badge = match source {
-            ExternalAgentSource::Extension => Some((
-                SharedString::new(format!("agent-source-{}", id)),
-                SharedString::from(format!(
-                    "The {} agent was installed from an extension.",
-                    display_name
-                )),
-                IconName::ZedSrcExtension,
-            )),
-            ExternalAgentSource::Registry => Some((
-                SharedString::new(format!("agent-source-{}", id)),
-                SharedString::from(format!(
-                    "The {} agent was installed from the ACP registry.",
-                    display_name
-                )),
-                IconName::AcpRegistry,
-            )),
-            ExternalAgentSource::Custom => None,
+        let source_kind = match source {
+            ExternalAgentSource::Extension => AiSettingItemSource::Extension,
+            ExternalAgentSource::Registry => AiSettingItemSource::Registry,
+            ExternalAgentSource::Custom => AiSettingItemSource::Custom,
         };
 
         let agent_server_name = AgentId(id.clone());
+        let agent = Agent::Custom {
+            id: agent_server_name.clone(),
+        };
+
+        let connection_status = self
+            .agent_connection_store
+            .read(cx)
+            .connection_status(&agent, cx);
+
+        let restart_button = matches!(
+            connection_status,
+            AgentConnectionStatus::Connected | AgentConnectionStatus::Connecting
+        )
+        .then(|| {
+            IconButton::new(
+                SharedString::from(format!("restart-{}", id)),
+                IconName::RotateCw,
+            )
+            .disabled(connection_status == AgentConnectionStatus::Connecting)
+            .icon_color(Color::Muted)
+            .icon_size(IconSize::Small)
+            .tooltip(Tooltip::text("Restart Agent Connection"))
+            .on_click(cx.listener({
+                let agent = agent.clone();
+                move |this, _, _window, cx| {
+                    let server: Rc<dyn agent_servers::AgentServer> =
+                        Rc::new(agent_servers::CustomAgentServer::new(agent.id()));
+                    this.agent_connection_store.update(cx, |store, cx| {
+                        store.restart_connection(agent.clone(), server, cx);
+                    });
+                }
+            }))
+        });
 
         let uninstall_button = match source {
             ExternalAgentSource::Extension => Some(
@@ -1301,32 +1244,16 @@ impl AgentConfiguration {
             }
         };
 
-        h_flex()
-            .gap_1()
-            .justify_between()
-            .child(
-                h_flex()
-                    .gap_1p5()
-                    .child(icon)
-                    .child(Label::new(display_name))
-                    .when_some(source_badge, |this, (tooltip_id, tooltip_message, icon)| {
-                        this.child(
-                            div()
-                                .id(tooltip_id)
-                                .flex_none()
-                                .tooltip(Tooltip::text(tooltip_message))
-                                .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)),
-                        )
-                    })
-                    .child(
-                        Icon::new(IconName::Check)
-                            .color(Color::Success)
-                            .size(IconSize::Small),
-                    ),
-            )
-            .when_some(uninstall_button, |this, uninstall_button| {
-                this.child(uninstall_button)
-            })
+        let status = match connection_status {
+            AgentConnectionStatus::Disconnected => AiSettingItemStatus::Stopped,
+            AgentConnectionStatus::Connecting => AiSettingItemStatus::Starting,
+            AgentConnectionStatus::Connected => AiSettingItemStatus::Running,
+        };
+
+        AiSettingItem::new(id, display_name, status, source_kind)
+            .icon(icon)
+            .when_some(restart_button, |this, button| this.action(button))
+            .when_some(uninstall_button, |this, button| this.action(button))
     }
 }
 

crates/agent_ui/src/agent_connection_store.rs πŸ”—

@@ -5,7 +5,8 @@ use agent_servers::{AgentServer, AgentServerDelegate};
 use anyhow::Result;
 use collections::HashMap;
 use futures::{FutureExt, future::Shared};
-use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Subscription, Task};
+use gpui::{App, AppContext, Context, Entity, EventEmitter, SharedString, Subscription, Task};
+
 use project::{AgentServerStore, AgentServersUpdated, Project};
 use watch::Receiver;
 
@@ -27,6 +28,13 @@ pub struct AgentConnectedState {
     pub history: Option<Entity<ThreadHistory>>,
 }
 
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum AgentConnectionStatus {
+    Disconnected,
+    Connecting,
+    Connected,
+}
+
 impl AgentConnectionEntry {
     pub fn wait_for_connection(&self) -> Shared<Task<Result<AgentConnectedState, LoadError>>> {
         match self {
@@ -42,6 +50,14 @@ impl AgentConnectionEntry {
             _ => None,
         }
     }
+
+    pub fn status(&self) -> AgentConnectionStatus {
+        match self {
+            AgentConnectionEntry::Connecting { .. } => AgentConnectionStatus::Connecting,
+            AgentConnectionEntry::Connected(_) => AgentConnectionStatus::Connected,
+            AgentConnectionEntry::Error { .. } => AgentConnectionStatus::Disconnected,
+        }
+    }
 }
 
 pub enum AgentConnectionEntryEvent {
@@ -71,66 +87,124 @@ impl AgentConnectionStore {
         self.entries.get(key)
     }
 
+    pub fn connection_status(&self, key: &Agent, cx: &App) -> AgentConnectionStatus {
+        self.entries
+            .get(key)
+            .map(|entry| entry.read(cx).status())
+            .unwrap_or(AgentConnectionStatus::Disconnected)
+    }
+
+    pub fn restart_connection(
+        &mut self,
+        key: Agent,
+        server: Rc<dyn AgentServer>,
+        cx: &mut Context<Self>,
+    ) -> Entity<AgentConnectionEntry> {
+        if let Some(entry) = self.entries.get(&key) {
+            if matches!(entry.read(cx), AgentConnectionEntry::Connecting { .. }) {
+                return entry.clone();
+            }
+        }
+
+        self.entries.remove(&key);
+        self.request_connection(key, server, cx)
+    }
+
     pub fn request_connection(
         &mut self,
         key: Agent,
         server: Rc<dyn AgentServer>,
         cx: &mut Context<Self>,
     ) -> Entity<AgentConnectionEntry> {
-        self.entries.get(&key).cloned().unwrap_or_else(|| {
-            let (mut new_version_rx, connect_task) = self.start_connection(server.clone(), cx);
-            let connect_task = connect_task.shared();
-
-            let entry = cx.new(|_cx| AgentConnectionEntry::Connecting {
-                connect_task: connect_task.clone(),
-            });
-
-            self.entries.insert(key.clone(), entry.clone());
-
-            cx.spawn({
-                let key = key.clone();
-                let entry = entry.clone();
-                async move |this, cx| match connect_task.await {
-                    Ok(connected_state) => {
-                        entry.update(cx, |entry, cx| {
-                            if let AgentConnectionEntry::Connecting { .. } = entry {
-                                *entry = AgentConnectionEntry::Connected(connected_state);
-                                cx.notify();
-                            }
-                        });
-                    }
-                    Err(error) => {
-                        entry.update(cx, |entry, cx| {
-                            if let AgentConnectionEntry::Connecting { .. } = entry {
-                                *entry = AgentConnectionEntry::Error { error };
-                                cx.notify();
-                            }
-                        });
-                        this.update(cx, |this, _cx| this.entries.remove(&key)).ok();
-                    }
+        if let Some(entry) = self.entries.get(&key) {
+            return entry.clone();
+        }
+
+        let (mut new_version_rx, connect_task) = self.start_connection(server, cx);
+        let connect_task = connect_task.shared();
+
+        let entry = cx.new(|_cx| AgentConnectionEntry::Connecting {
+            connect_task: connect_task.clone(),
+        });
+
+        self.entries.insert(key.clone(), entry.clone());
+        cx.notify();
+
+        cx.spawn({
+            let key = key.clone();
+            let entry = entry.downgrade();
+            async move |this, cx| match connect_task.await {
+                Ok(connected_state) => {
+                    this.update(cx, move |this, cx| {
+                        if this.entries.get(&key) != entry.upgrade().as_ref() {
+                            return;
+                        }
+
+                        entry
+                            .update(cx, move |entry, cx| {
+                                if let AgentConnectionEntry::Connecting { .. } = entry {
+                                    *entry = AgentConnectionEntry::Connected(connected_state);
+                                    cx.notify();
+                                }
+                            })
+                            .ok();
+                    })
+                    .ok();
                 }
-            })
-            .detach();
-
-            cx.spawn({
-                let entry = entry.clone();
-                async move |this, cx| {
-                    while let Ok(version) = new_version_rx.recv().await {
-                        if let Some(version) = version {
-                            entry.update(cx, |_entry, cx| {
-                                cx.emit(AgentConnectionEntryEvent::NewVersionAvailable(
-                                    version.clone().into(),
-                                ));
-                            });
-                            this.update(cx, |this, _cx| this.entries.remove(&key)).ok();
+                Err(error) => {
+                    this.update(cx, move |this, cx| {
+                        if this.entries.get(&key) != entry.upgrade().as_ref() {
+                            return;
                         }
-                    }
+
+                        entry
+                            .update(cx, move |entry, cx| {
+                                if let AgentConnectionEntry::Connecting { .. } = entry {
+                                    *entry = AgentConnectionEntry::Error { error };
+                                    cx.notify();
+                                }
+                            })
+                            .ok();
+                        this.entries.remove(&key);
+                        cx.notify();
+                    })
+                    .ok();
                 }
-            })
-            .detach();
+            }
+        })
+        .detach();
+
+        cx.spawn({
+            let entry = entry.downgrade();
+            async move |this, cx| {
+                while let Ok(version) = new_version_rx.recv().await {
+                    let Some(version) = version else {
+                        continue;
+                    };
+
+                    this.update(cx, move |this, cx| {
+                        if this.entries.get(&key) != entry.upgrade().as_ref() {
+                            return;
+                        }
 
-            entry
+                        entry
+                            .update(cx, move |_entry, cx| {
+                                cx.emit(AgentConnectionEntryEvent::NewVersionAvailable(
+                                    version.into(),
+                                ));
+                            })
+                            .ok();
+                        this.entries.remove(&key);
+                        cx.notify();
+                    })
+                    .ok();
+                    break;
+                }
+            }
         })
+        .detach();
+
+        entry
     }
 
     fn handle_agent_servers_updated(

crates/agent_ui/src/agent_panel.rs πŸ”—

@@ -1179,7 +1179,8 @@ impl AgentPanel {
     }
 
     pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context<Self>) {
-        self.new_agent_thread(AgentType::NativeAgent, window, cx);
+        self.reset_start_thread_in_to_default(cx);
+        self.external_thread(None, None, None, None, None, true, window, cx);
     }
 
     fn new_native_agent_thread_from_summary(
@@ -1688,6 +1689,7 @@ impl AgentPanel {
             AgentConfiguration::new(
                 fs,
                 agent_server_store,
+                self.connection_store.clone(),
                 context_server_store,
                 self.context_server_registry.clone(),
                 self.language_registry.clone(),
@@ -3184,13 +3186,17 @@ impl Panel for AgentPanel {
     }
 
     fn activation_priority(&self) -> u32 {
-        8
+        0
     }
 
     fn enabled(&self, cx: &App) -> bool {
         AgentSettings::get_global(cx).enabled(cx)
     }
 
+    fn is_agent_panel(&self) -> bool {
+        true
+    }
+
     fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {
         self.zoomed
     }
@@ -3821,8 +3827,6 @@ impl AgentPanel {
                                     }
                                 }),
                         )
-                        .separator()
-                        .header("External Agents")
                         .map(|mut menu| {
                             let agent_server_store = agent_server_store.read(cx);
                             let registry_store = project::AgentRegistryStore::try_global(cx);
@@ -3853,6 +3857,9 @@ impl AgentPanel {
                                 .sorted_unstable_by_key(|e| e.display_name.to_lowercase())
                                 .collect::<Vec<_>>();
 
+                            if !agent_items.is_empty() {
+                                menu = menu.separator().header("External Agents");
+                            }
                             for item in &agent_items {
                                 let mut entry = ContextMenuEntry::new(item.display_name.clone());
 

crates/agent_ui/src/agent_ui.rs πŸ”—

@@ -502,7 +502,6 @@ fn update_command_palette_filter(cx: &mut App) {
                 | EditPredictionProvider::Codestral
                 | EditPredictionProvider::Ollama
                 | EditPredictionProvider::OpenAiCompatibleApi
-                | EditPredictionProvider::Sweep
                 | EditPredictionProvider::Mercury
                 | EditPredictionProvider::Experimental(_) => {
                     filter.show_namespace("edit_prediction");
@@ -649,7 +648,6 @@ mod tests {
             default_profile: AgentProfileId::default(),
             default_view: DefaultAgentView::Thread,
             profiles: Default::default(),
-
             notify_when_agent_waiting: NotifyWhenAgentWaiting::default(),
             play_sound_when_agent_done: false,
             single_file_review: false,
@@ -663,6 +661,7 @@ mod tests {
             tool_permissions: Default::default(),
             show_turn_stats: false,
             new_thread_location: Default::default(),
+            sidebar_side: Default::default(),
         };
 
         cx.update(|cx| {

crates/agent_ui/src/conversation_view.rs πŸ”—

@@ -237,6 +237,20 @@ impl Conversation {
         ))
     }
 
+    pub fn subagents_awaiting_permission(&self, cx: &App) -> Vec<(acp::SessionId, usize)> {
+        self.permission_requests
+            .iter()
+            .filter_map(|(session_id, tool_call_ids)| {
+                let thread = self.threads.get(session_id)?;
+                if thread.read(cx).parent_session_id().is_some() && !tool_call_ids.is_empty() {
+                    Some((session_id.clone(), tool_call_ids.len()))
+                } else {
+                    None
+                }
+            })
+            .collect()
+    }
+
     pub fn authorize_pending_tool_call(
         &mut self,
         session_id: &acp::SessionId,
@@ -795,7 +809,7 @@ impl ConversationView {
         });
 
         let count = thread.read(cx).entries().len();
-        let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0));
+        let list_state = ListState::new(0, gpui::ListAlignment::Top, px(2048.0));
         entry_view_state.update(cx, |view_state, cx| {
             for ix in 0..count {
                 view_state.sync_entry(ix, &thread, window, cx);
@@ -1255,9 +1269,11 @@ impl ConversationView {
             }
             AcpThreadEvent::Stopped(stop_reason) => {
                 if let Some(active) = self.thread_view(&thread_id) {
-                    active.update(cx, |active, _cx| {
+                    active.update(cx, |active, cx| {
                         active.thread_retry_status.take();
                         active.clear_auto_expand_tracking();
+                        active.list_state.set_follow_tail(false);
+                        active.sync_generating_indicator(cx);
                     });
                 }
                 if is_subagent {
@@ -1325,8 +1341,10 @@ impl ConversationView {
             }
             AcpThreadEvent::Error => {
                 if let Some(active) = self.thread_view(&thread_id) {
-                    active.update(cx, |active, _cx| {
+                    active.update(cx, |active, cx| {
                         active.thread_retry_status.take();
+                        active.list_state.set_follow_tail(false);
+                        active.sync_generating_indicator(cx);
                     });
                 }
                 if !is_subagent {

crates/agent_ui/src/conversation_view/thread_view.rs πŸ”—

@@ -1,7 +1,10 @@
-use crate::{DEFAULT_THREAD_TITLE, SelectPermissionGranularity};
+use crate::{
+    DEFAULT_THREAD_TITLE, SelectPermissionGranularity,
+    agent_configuration::configure_context_server_modal::default_markdown_style,
+};
 use std::cell::RefCell;
 
-use acp_thread::ContentBlock;
+use acp_thread::{ContentBlock, PlanEntry};
 use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody};
 use editor::actions::OpenExcerpts;
 
@@ -285,6 +288,7 @@ pub struct ThreadView {
     pub hovered_recent_history_item: Option<usize>,
     pub show_external_source_prompt_warning: bool,
     pub show_codex_windows_warning: bool,
+    pub generating_indicator_in_list: bool,
     pub history: Option<Entity<ThreadHistory>>,
     pub _history_subscription: Option<Subscription>,
 }
@@ -525,19 +529,39 @@ impl ThreadView {
             history,
             _history_subscription: history_subscription,
             show_codex_windows_warning,
+            generating_indicator_in_list: false,
         };
+
+        this.sync_generating_indicator(cx);
         let list_state_for_scroll = this.list_state.clone();
         let thread_view = cx.entity().downgrade();
+
         this.list_state
-            .set_scroll_handler(move |_event, _window, cx| {
+            .set_scroll_handler(move |event, _window, cx| {
                 let list_state = list_state_for_scroll.clone();
                 let thread_view = thread_view.clone();
+                let is_following_tail = event.is_following_tail;
                 // N.B. We must defer because the scroll handler is called while the
                 // ListState's RefCell is mutably borrowed. Reading logical_scroll_top()
                 // directly would panic from a double borrow.
                 cx.defer(move |cx| {
                     let scroll_top = list_state.logical_scroll_top();
                     let _ = thread_view.update(cx, |this, cx| {
+                        if !is_following_tail {
+                            let is_at_bottom = {
+                                let current_offset =
+                                    list_state.scroll_px_offset_for_scrollbar().y.abs();
+                                let max_offset = list_state.max_offset_for_scrollbar().y;
+                                current_offset >= max_offset - px(1.0)
+                            };
+
+                            let is_generating =
+                                matches!(this.thread.read(cx).status(), ThreadStatus::Generating);
+
+                            if is_at_bottom && is_generating {
+                                list_state.set_follow_tail(true);
+                            }
+                        }
                         if let Some(thread) = this.as_native_thread(cx) {
                             thread.update(cx, |thread, _cx| {
                                 thread.set_ui_scroll_position(Some(scroll_top));
@@ -1043,7 +1067,11 @@ impl ThreadView {
             this.update_in(cx, |this, _window, cx| {
                 this.set_editor_is_expanded(false, cx);
             })?;
-            let _ = this.update(cx, |this, cx| this.scroll_to_bottom(cx));
+
+            let _ = this.update(cx, |this, cx| {
+                this.list_state.set_follow_tail(true);
+                cx.notify();
+            });
 
             let _stop_turn = defer({
                 let this = this.clone();
@@ -1097,6 +1125,12 @@ impl ThreadView {
 
                 thread.send(contents, cx)
             })?;
+
+            let _ = this.update(cx, |this, cx| {
+                this.sync_generating_indicator(cx);
+                cx.notify();
+            });
+
             let res = send.await;
             let turn_time_ms = turn_start_time.elapsed().as_millis();
             drop(_stop_turn);
@@ -1236,13 +1270,13 @@ impl ThreadView {
         );
     }
 
-    // generation
-
     pub fn cancel_generation(&mut self, cx: &mut Context<Self>) {
         self.thread_retry_status.take();
         self.thread_error.take();
         self.user_interrupted_generation = true;
         self._cancel_task = Some(self.thread.update(cx, |thread, cx| thread.cancel(cx)));
+        self.sync_generating_indicator(cx);
+        cx.notify();
     }
 
     pub fn retry_generation(&mut self, cx: &mut Context<Self>) {
@@ -1254,6 +1288,8 @@ impl ThreadView {
         }
 
         let task = thread.update(cx, |thread, cx| thread.retry(cx));
+        self.sync_generating_indicator(cx);
+        cx.notify();
         cx.spawn(async move |this, cx| {
             let result = task.await;
 
@@ -1582,11 +1618,10 @@ impl ThreadView {
                 }
             })
         };
+        self.message_editor.focus_handle(cx).focus(window, cx);
         cx.notify();
     }
 
-    // tool permissions
-
     pub fn authorize_tool_call(
         &mut self,
         session_id: acp::SessionId,
@@ -1640,6 +1675,17 @@ impl ThreadView {
         Some(())
     }
 
+    fn is_waiting_for_confirmation(entry: &AgentThreadEntry) -> bool {
+        if let AgentThreadEntry::ToolCall(tool_call) = entry {
+            matches!(
+                tool_call.status,
+                ToolCallStatus::WaitingForConfirmation { .. }
+            )
+        } else {
+            false
+        }
+    }
+
     fn handle_authorize_tool_call(
         &mut self,
         action: &AuthorizeToolCall,
@@ -2112,7 +2158,14 @@ impl ThreadView {
         let plan = thread.plan();
         let queue_is_empty = !self.has_queued_messages();
 
-        if changed_buffers.is_empty() && plan.is_empty() && queue_is_empty {
+        let subagents_awaiting_permission = self.render_subagents_awaiting_permission(cx);
+        let has_subagents_awaiting = subagents_awaiting_permission.is_some();
+
+        if changed_buffers.is_empty()
+            && plan.is_empty()
+            && queue_is_empty
+            && !has_subagents_awaiting
+        {
             return None;
         }
 
@@ -2140,6 +2193,14 @@ impl ThreadView {
                 blur_radius: px(2.),
                 spread_radius: px(0.),
             }])
+            .when_some(subagents_awaiting_permission, |this, element| {
+                this.child(element)
+            })
+            .when(
+                has_subagents_awaiting
+                    && (!plan.is_empty() || !changed_buffers.is_empty() || !queue_is_empty),
+                |this| this.child(Divider::horizontal().color(DividerColor::Border)),
+            )
             .when(!plan.is_empty(), |this| {
                 this.child(self.render_plan_summary(plan, window, cx))
                     .when(plan_expanded, |parent| {
@@ -2399,6 +2460,119 @@ impl ThreadView {
             )
     }
 
+    fn render_subagents_awaiting_permission(&self, cx: &Context<Self>) -> Option<AnyElement> {
+        let awaiting = self.conversation.read(cx).subagents_awaiting_permission(cx);
+
+        if awaiting.is_empty() {
+            return None;
+        }
+
+        let thread = self.thread.read(cx);
+        let entries = thread.entries();
+        let mut subagent_items: Vec<(SharedString, usize)> = Vec::new();
+
+        for (session_id, _) in &awaiting {
+            for (entry_ix, entry) in entries.iter().enumerate() {
+                if let AgentThreadEntry::ToolCall(tool_call) = entry {
+                    if let Some(info) = &tool_call.subagent_session_info {
+                        if &info.session_id == session_id {
+                            let subagent_summary: SharedString = {
+                                let summary_text = tool_call.label.read(cx).source().to_string();
+                                if !summary_text.is_empty() {
+                                    summary_text.into()
+                                } else {
+                                    "Subagent".into()
+                                }
+                            };
+                            subagent_items.push((subagent_summary, entry_ix));
+                            break;
+                        }
+                    }
+                }
+            }
+        }
+
+        if subagent_items.is_empty() {
+            return None;
+        }
+
+        let item_count = subagent_items.len();
+
+        Some(
+            v_flex()
+                .child(
+                    h_flex()
+                        .py_1()
+                        .px_2()
+                        .w_full()
+                        .gap_1()
+                        .border_b_1()
+                        .border_color(cx.theme().colors().border)
+                        .child(
+                            Label::new("Subagents Awaiting Permission:")
+                                .size(LabelSize::Small)
+                                .color(Color::Muted),
+                        )
+                        .child(Label::new(item_count.to_string()).size(LabelSize::Small)),
+                )
+                .child(
+                    v_flex().children(subagent_items.into_iter().enumerate().map(
+                        |(ix, (label, entry_ix))| {
+                            let is_last = ix == item_count - 1;
+                            let group = format!("group-{}", entry_ix);
+
+                            h_flex()
+                                .cursor_pointer()
+                                .id(format!("subagent-permission-{}", entry_ix))
+                                .group(&group)
+                                .p_1()
+                                .pl_2()
+                                .min_w_0()
+                                .w_full()
+                                .gap_1()
+                                .justify_between()
+                                .bg(cx.theme().colors().editor_background)
+                                .hover(|s| s.bg(cx.theme().colors().element_hover))
+                                .when(!is_last, |this| {
+                                    this.border_b_1().border_color(cx.theme().colors().border)
+                                })
+                                .child(
+                                    h_flex()
+                                        .gap_1p5()
+                                        .child(
+                                            Icon::new(IconName::Circle)
+                                                .size(IconSize::XSmall)
+                                                .color(Color::Warning),
+                                        )
+                                        .child(
+                                            Label::new(label)
+                                                .size(LabelSize::Small)
+                                                .color(Color::Muted)
+                                                .truncate(),
+                                        ),
+                                )
+                                .child(
+                                    div().visible_on_hover(&group).child(
+                                        Label::new("Scroll to Subagent")
+                                            .size(LabelSize::Small)
+                                            .color(Color::Muted)
+                                            .truncate(),
+                                    ),
+                                )
+                                .on_click(cx.listener(move |this, _, _, cx| {
+                                    this.list_state.scroll_to(ListOffset {
+                                        item_ix: entry_ix,
+                                        offset_in_item: px(0.0),
+                                    });
+                                    cx.notify();
+                                }))
+                        },
+                    )),
+                )
+                .into_any(),
+        )
+    }
+
     fn render_message_queue_summary(
         &self,
         _window: &mut Window,
@@ -2540,7 +2714,17 @@ impl ThreadView {
                 this.border_b_1().border_color(cx.theme().colors().border)
             })
             .child(Disclosure::new("plan_disclosure", plan_expanded))
-            .child(title)
+            .child(title.flex_1())
+            .child(
+                IconButton::new("dismiss-plan", IconName::Close)
+                    .icon_size(IconSize::XSmall)
+                    .shape(ui::IconButtonShape::Square)
+                    .tooltip(Tooltip::text("Clear plan"))
+                    .on_click(cx.listener(|this, _, _, cx| {
+                        this.thread.update(cx, |thread, cx| thread.clear_plan(cx));
+                        cx.stop_propagation();
+                    })),
+            )
             .on_click(cx.listener(|this, _, _, cx| {
                 this.plan_expanded = !this.plan_expanded;
                 cx.notify();
@@ -2608,6 +2792,76 @@ impl ThreadView {
             .into_any_element()
     }
 
+    fn render_completed_plan(
+        &self,
+        entries: &[PlanEntry],
+        window: &Window,
+        cx: &Context<Self>,
+    ) -> AnyElement {
+        v_flex()
+            .px_5()
+            .py_1p5()
+            .w_full()
+            .child(
+                v_flex()
+                    .w_full()
+                    .rounded_md()
+                    .border_1()
+                    .border_color(self.tool_card_border_color(cx))
+                    .child(
+                        h_flex()
+                            .px_2()
+                            .py_1()
+                            .gap_1()
+                            .bg(self.tool_card_header_bg(cx))
+                            .border_b_1()
+                            .border_color(self.tool_card_border_color(cx))
+                            .child(
+                                Label::new("Completed Plan")
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted),
+                            )
+                            .child(
+                                Label::new(format!(
+                                    "β€” {} {}",
+                                    entries.len(),
+                                    if entries.len() == 1 { "step" } else { "steps" }
+                                ))
+                                .size(LabelSize::Small)
+                                .color(Color::Muted),
+                            ),
+                    )
+                    .child(
+                        v_flex().children(entries.iter().enumerate().map(|(index, entry)| {
+                            h_flex()
+                                .py_1()
+                                .px_2()
+                                .gap_1p5()
+                                .when(index < entries.len() - 1, |this| {
+                                    this.border_b_1().border_color(cx.theme().colors().border)
+                                })
+                                .child(
+                                    Icon::new(IconName::TodoComplete)
+                                        .size(IconSize::Small)
+                                        .color(Color::Success),
+                                )
+                                .child(
+                                    div()
+                                        .max_w_full()
+                                        .overflow_x_hidden()
+                                        .text_xs()
+                                        .text_color(cx.theme().colors().text_muted)
+                                        .child(MarkdownElement::new(
+                                            entry.content.clone(),
+                                            default_markdown_style(window, cx),
+                                        )),
+                                )
+                        })),
+                    ),
+            )
+            .into_any()
+    }
+
     fn render_edits_summary(
         &self,
         changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
@@ -3197,22 +3451,98 @@ impl ThreadView {
                 })
         };
 
-        if show_split {
-            let max_output_tokens = self
-                .as_native_thread(cx)
-                .and_then(|thread| thread.read(cx).model())
-                .and_then(|model| model.max_output_tokens())
-                .unwrap_or(0);
+        let used = crate::text_thread_editor::humanize_token_count(usage.used_tokens);
+        let max = crate::text_thread_editor::humanize_token_count(usage.max_tokens);
+        let input_tokens_label =
+            crate::text_thread_editor::humanize_token_count(usage.input_tokens);
+        let output_tokens_label =
+            crate::text_thread_editor::humanize_token_count(usage.output_tokens);
+
+        let progress_ratio = if usage.max_tokens > 0 {
+            usage.used_tokens as f32 / usage.max_tokens as f32
+        } else {
+            0.0
+        };
+        let percentage = format!("{}%", (progress_ratio * 100.0).round() as u32);
+
+        let tooltip_separator_color = Color::Custom(cx.theme().colors().text_disabled.opacity(0.6));
+
+        let (user_rules_count, first_user_rules_id, project_rules_count, project_entry_ids) = self
+            .as_native_thread(cx)
+            .map(|thread| {
+                let project_context = thread.read(cx).project_context().read(cx);
+                let user_rules_count = project_context.user_rules.len();
+                let first_user_rules_id = project_context.user_rules.first().map(|r| r.uuid.0);
+                let project_entry_ids = project_context
+                    .worktrees
+                    .iter()
+                    .filter_map(|wt| wt.rules_file.as_ref())
+                    .map(|rf| ProjectEntryId::from_usize(rf.project_entry_id))
+                    .collect::<Vec<_>>();
+                let project_rules_count = project_entry_ids.len();
+                (
+                    user_rules_count,
+                    first_user_rules_id,
+                    project_rules_count,
+                    project_entry_ids,
+                )
+            })
+            .unwrap_or_default();
+
+        let workspace = self.workspace.clone();
+
+        let max_output_tokens = self
+            .as_native_thread(cx)
+            .and_then(|thread| thread.read(cx).model())
+            .and_then(|model| model.max_output_tokens())
+            .unwrap_or(0);
+        let input_max_label = crate::text_thread_editor::humanize_token_count(
+            usage.max_tokens.saturating_sub(max_output_tokens),
+        );
+        let output_max_label = crate::text_thread_editor::humanize_token_count(max_output_tokens);
+
+        let build_tooltip = {
+            let input_max_label = input_max_label.clone();
+            let output_max_label = output_max_label.clone();
+            move |_window: &mut Window, cx: &mut App| {
+                let percentage = percentage.clone();
+                let used = used.clone();
+                let max = max.clone();
+                let input_tokens_label = input_tokens_label.clone();
+                let output_tokens_label = output_tokens_label.clone();
+                let input_max_label = input_max_label.clone();
+                let output_max_label = output_max_label.clone();
+                let project_entry_ids = project_entry_ids.clone();
+                let workspace = workspace.clone();
+                cx.new(move |_cx| TokenUsageTooltip {
+                    percentage,
+                    used,
+                    max,
+                    input_tokens: input_tokens_label,
+                    output_tokens: output_tokens_label,
+                    input_max: input_max_label,
+                    output_max: output_max_label,
+                    show_split,
+                    separator_color: tooltip_separator_color,
+                    user_rules_count,
+                    first_user_rules_id,
+                    project_rules_count,
+                    project_entry_ids,
+                    workspace,
+                })
+                .into()
+            }
+        };
 
+        if show_split {
             let input = crate::text_thread_editor::humanize_token_count(usage.input_tokens);
-            let input_max = crate::text_thread_editor::humanize_token_count(
-                usage.max_tokens.saturating_sub(max_output_tokens),
-            );
+            let input_max = input_max_label;
             let output = crate::text_thread_editor::humanize_token_count(usage.output_tokens);
-            let output_max = crate::text_thread_editor::humanize_token_count(max_output_tokens);
+            let output_max = output_max_label;
 
             Some(
                 h_flex()
+                    .id("split_token_usage")
                     .flex_shrink_0()
                     .gap_1()
                     .mr_1p5()
@@ -3256,39 +3586,15 @@ impl ThreadView {
                                     .color(Color::Muted),
                             ),
                     )
+                    .hoverable_tooltip(build_tooltip)
                     .into_any_element(),
             )
         } else {
-            let used = crate::text_thread_editor::humanize_token_count(usage.used_tokens);
-            let max = crate::text_thread_editor::humanize_token_count(usage.max_tokens);
-            let progress_ratio = if usage.max_tokens > 0 {
-                usage.used_tokens as f32 / usage.max_tokens as f32
-            } else {
-                0.0
-            };
-
             let progress_color = if progress_ratio >= 0.85 {
                 cx.theme().status().warning
             } else {
                 cx.theme().colors().text_muted
             };
-            let separator_color = Color::Custom(cx.theme().colors().text_disabled.opacity(0.6));
-
-            let percentage = format!("{}%", (progress_ratio * 100.0).round() as u32);
-
-            let (user_rules_count, project_rules_count) = self
-                .as_native_thread(cx)
-                .map(|thread| {
-                    let project_context = thread.read(cx).project_context().read(cx);
-                    let user_rules = project_context.user_rules.len();
-                    let project_rules = project_context
-                        .worktrees
-                        .iter()
-                        .filter(|wt| wt.rules_file.is_some())
-                        .count();
-                    (user_rules, project_rules)
-                })
-                .unwrap_or((0, 0));
 
             Some(
                 h_flex()
@@ -3305,53 +3611,7 @@ impl ThreadView {
                         .stroke_width(px(2.))
                         .progress_color(progress_color),
                     )
-                    .tooltip(Tooltip::element({
-                        move |_, cx| {
-                            v_flex()
-                                .min_w_40()
-                                .child(
-                                    Label::new("Context")
-                                        .color(Color::Muted)
-                                        .size(LabelSize::Small),
-                                )
-                                .child(
-                                    h_flex()
-                                        .gap_0p5()
-                                        .child(Label::new(percentage.clone()))
-                                        .child(Label::new("β€’").color(separator_color).mx_1())
-                                        .child(Label::new(used.clone()))
-                                        .child(Label::new("/").color(separator_color))
-                                        .child(Label::new(max.clone()).color(Color::Muted)),
-                                )
-                                .when(user_rules_count > 0 || project_rules_count > 0, |this| {
-                                    this.child(
-                                        v_flex()
-                                            .mt_1p5()
-                                            .pt_1p5()
-                                            .border_t_1()
-                                            .border_color(cx.theme().colors().border_variant)
-                                            .child(
-                                                Label::new("Rules")
-                                                    .color(Color::Muted)
-                                                    .size(LabelSize::Small),
-                                            )
-                                            .when(user_rules_count > 0, |this| {
-                                                this.child(Label::new(format!(
-                                                    "{} user rules",
-                                                    user_rules_count
-                                                )))
-                                            })
-                                            .when(project_rules_count > 0, |this| {
-                                                this.child(Label::new(format!(
-                                                    "{} project rules",
-                                                    project_rules_count
-                                                )))
-                                            }),
-                                    )
-                                })
-                                .into_any_element()
-                        }
-                    }))
+                    .hoverable_tooltip(build_tooltip)
                     .into_any_element(),
             )
         }
@@ -3900,16 +4160,184 @@ impl ThreadView {
     }
 }
 
+struct TokenUsageTooltip {
+    percentage: String,
+    used: String,
+    max: String,
+    input_tokens: String,
+    output_tokens: String,
+    input_max: String,
+    output_max: String,
+    show_split: bool,
+    separator_color: Color,
+    user_rules_count: usize,
+    first_user_rules_id: Option<uuid::Uuid>,
+    project_rules_count: usize,
+    project_entry_ids: Vec<ProjectEntryId>,
+    workspace: WeakEntity<Workspace>,
+}
+
+impl Render for TokenUsageTooltip {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let separator_color = self.separator_color;
+        let percentage = self.percentage.clone();
+        let used = self.used.clone();
+        let max = self.max.clone();
+        let input_tokens = self.input_tokens.clone();
+        let output_tokens = self.output_tokens.clone();
+        let input_max = self.input_max.clone();
+        let output_max = self.output_max.clone();
+        let show_split = self.show_split;
+        let user_rules_count = self.user_rules_count;
+        let first_user_rules_id = self.first_user_rules_id;
+        let project_rules_count = self.project_rules_count;
+        let project_entry_ids = self.project_entry_ids.clone();
+        let workspace = self.workspace.clone();
+
+        ui::tooltip_container(cx, move |container, cx| {
+            container
+                .min_w_40()
+                .when(!show_split, |this| {
+                    this.child(
+                        Label::new("Context")
+                            .color(Color::Muted)
+                            .size(LabelSize::Small),
+                    )
+                    .child(
+                        h_flex()
+                            .gap_0p5()
+                            .child(Label::new(percentage.clone()))
+                            .child(Label::new("\u{2022}").color(separator_color).mx_1())
+                            .child(Label::new(used.clone()))
+                            .child(Label::new("/").color(separator_color))
+                            .child(Label::new(max.clone()).color(Color::Muted)),
+                    )
+                })
+                .when(show_split, |this| {
+                    this.child(
+                        v_flex()
+                            .gap_0p5()
+                            .child(
+                                h_flex()
+                                    .gap_0p5()
+                                    .child(Label::new("Input:").color(Color::Muted).mr_0p5())
+                                    .child(Label::new(input_tokens))
+                                    .child(Label::new("/").color(separator_color))
+                                    .child(Label::new(input_max).color(Color::Muted)),
+                            )
+                            .child(
+                                h_flex()
+                                    .gap_0p5()
+                                    .child(Label::new("Output:").color(Color::Muted).mr_0p5())
+                                    .child(Label::new(output_tokens))
+                                    .child(Label::new("/").color(separator_color))
+                                    .child(Label::new(output_max).color(Color::Muted)),
+                            ),
+                    )
+                })
+                .when(
+                    user_rules_count > 0 || project_rules_count > 0,
+                    move |this| {
+                        this.child(
+                            v_flex()
+                                .mt_1p5()
+                                .pt_1p5()
+                                .pb_0p5()
+                                .gap_0p5()
+                                .border_t_1()
+                                .border_color(cx.theme().colors().border_variant)
+                                .child(
+                                    Label::new("Rules")
+                                        .color(Color::Muted)
+                                        .size(LabelSize::Small),
+                                )
+                                .child(
+                                    v_flex()
+                                        .mx_neg_1()
+                                        .when(user_rules_count > 0, move |this| {
+                                            this.child(
+                                                Button::new(
+                                                    "open-user-rules",
+                                                    format!("{} user rules", user_rules_count),
+                                                )
+                                                .end_icon(
+                                                    Icon::new(IconName::ArrowUpRight)
+                                                        .color(Color::Muted)
+                                                        .size(IconSize::XSmall),
+                                                )
+                                                .on_click(move |_, window, cx| {
+                                                    window.dispatch_action(
+                                                        Box::new(OpenRulesLibrary {
+                                                            prompt_to_select: first_user_rules_id,
+                                                        }),
+                                                        cx,
+                                                    );
+                                                }),
+                                            )
+                                        })
+                                        .when(project_rules_count > 0, move |this| {
+                                            let workspace = workspace.clone();
+                                            let project_entry_ids = project_entry_ids.clone();
+                                            this.child(
+                                                Button::new(
+                                                    "open-project-rules",
+                                                    format!(
+                                                        "{} project rules",
+                                                        project_rules_count
+                                                    ),
+                                                )
+                                                .end_icon(
+                                                    Icon::new(IconName::ArrowUpRight)
+                                                        .color(Color::Muted)
+                                                        .size(IconSize::XSmall),
+                                                )
+                                                .on_click(move |_, window, cx| {
+                                                    let _ =
+                                                        workspace.update(cx, |workspace, cx| {
+                                                            let project =
+                                                                workspace.project().read(cx);
+                                                            let paths = project_entry_ids
+                                                                .iter()
+                                                                .flat_map(|id| {
+                                                                    project.path_for_entry(*id, cx)
+                                                                })
+                                                                .collect::<Vec<_>>();
+                                                            for path in paths {
+                                                                workspace
+                                                                    .open_path(
+                                                                        path, None, true, window,
+                                                                        cx,
+                                                                    )
+                                                                    .detach_and_log_err(cx);
+                                                            }
+                                                        });
+                                                }),
+                                            )
+                                        }),
+                                ),
+                        )
+                    },
+                )
+        })
+    }
+}
+
 impl ThreadView {
     pub(crate) fn render_entries(&mut self, cx: &mut Context<Self>) -> List {
         list(
             self.list_state.clone(),
             cx.processor(|this, index: usize, window, cx| {
                 let entries = this.thread.read(cx).entries();
-                let Some(entry) = entries.get(index) else {
-                    return Empty.into_any();
-                };
-                this.render_entry(index, entries.len(), entry, window, cx)
+                if let Some(entry) = entries.get(index) {
+                    this.render_entry(index, entries.len(), entry, window, cx)
+                } else if this.generating_indicator_in_list {
+                    let confirmation = entries
+                        .last()
+                        .is_some_and(|entry| Self::is_waiting_for_confirmation(entry));
+                    this.render_generating(confirmation, cx).into_any_element()
+                } else {
+                    Empty.into_any()
+                }
             }),
         )
         .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
@@ -3949,12 +4377,6 @@ impl ThreadView {
                 let editor_focus = editor.focus_handle(cx).is_focused(window);
                 let focus_border = cx.theme().colors().border_focused;
 
-                let rules_item = if entry_ix == 0 {
-                    self.render_rules_item(cx)
-                } else {
-                    None
-                };
-
                 let has_checkpoint_button = message
                     .checkpoint
                     .as_ref()
@@ -3973,10 +4395,6 @@ impl ThreadView {
                     .map(|this| {
                         if is_first_indented {
                             this.pt_0p5()
-                        } else if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none()  {
-                            this.pt(rems_from_px(18.))
-                        } else if rules_item.is_some() {
-                            this.pt_3()
                         } else {
                             this.pt_2()
                         }
@@ -3985,7 +4403,6 @@ impl ThreadView {
                     .px_2()
                     .gap_1p5()
                     .w_full()
-                    .children(rules_item)
                     .when(is_editable && has_checkpoint_button, |this| {
                         this.children(message.id.clone().map(|message_id| {
                             h_flex()
@@ -4202,6 +4619,9 @@ impl ThreadView {
                     cx,
                 )
                 .into_any(),
+            AgentThreadEntry::CompletedPlan(entries) => {
+                self.render_completed_plan(entries, window, cx)
+            }
         };
 
         let is_subagent_output = self.is_subagent()
@@ -4240,6 +4660,8 @@ impl ThreadView {
             primary
         };
 
+        let thread = self.thread.clone();
+
         let primary = if is_indented {
             let line_top = if is_first_indented {
                 rems_from_px(-12.0)
@@ -4267,28 +4689,16 @@ impl ThreadView {
             primary
         };
 
-        let needs_confirmation = if let AgentThreadEntry::ToolCall(tool_call) = entry {
-            matches!(
-                tool_call.status,
-                ToolCallStatus::WaitingForConfirmation { .. }
-            )
-        } else {
-            false
-        };
+        let needs_confirmation = Self::is_waiting_for_confirmation(entry);
 
-        let thread = self.thread.clone();
         let comments_editor = self.thread_feedback.comments_editor.clone();
 
         let primary = if entry_ix + 1 == total_entries {
             v_flex()
                 .w_full()
                 .child(primary)
-                .map(|this| {
-                    if needs_confirmation {
-                        this.child(self.render_generating(true, cx))
-                    } else {
-                        this.child(self.render_thread_controls(&thread, cx))
-                    }
+                .when(!needs_confirmation, |this| {
+                    this.child(self.render_thread_controls(&thread, cx))
                 })
                 .when_some(comments_editor, |this, editor| {
                     this.child(Self::render_feedback_feedback_editor(editor, cx))
@@ -4372,7 +4782,7 @@ impl ThreadView {
     ) -> impl IntoElement {
         let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating);
         if is_generating {
-            return self.render_generating(false, cx).into_any_element();
+            return Empty.into_any_element();
         }
 
         let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown)
@@ -4572,13 +4982,12 @@ impl ThreadView {
             });
             cx.notify();
         } else {
-            self.scroll_to_bottom(cx);
+            self.scroll_to_end(cx);
         }
     }
 
-    pub fn scroll_to_bottom(&mut self, cx: &mut Context<Self>) {
-        let entry_count = self.thread.read(cx).entries().len();
-        self.list_state.reset(entry_count);
+    pub fn scroll_to_end(&mut self, cx: &mut Context<Self>) {
+        self.list_state.scroll_to_end();
         cx.notify();
     }
 
@@ -4659,6 +5068,21 @@ impl ThreadView {
         })
     }
 
+    /// Ensures the list item count includes (or excludes) an extra item for the generating indicator
+    pub(crate) fn sync_generating_indicator(&mut self, cx: &App) {
+        let is_generating = matches!(self.thread.read(cx).status(), ThreadStatus::Generating);
+
+        if is_generating && !self.generating_indicator_in_list {
+            let entries_count = self.thread.read(cx).entries().len();
+            self.list_state.splice(entries_count..entries_count, 1);
+            self.generating_indicator_in_list = true;
+        } else if !is_generating && self.generating_indicator_in_list {
+            let entries_count = self.thread.read(cx).entries().len();
+            self.list_state.splice(entries_count..entries_count + 1, 0);
+            self.generating_indicator_in_list = false;
+        }
+    }
+
     fn render_generating(&self, confirmation: bool, cx: &App) -> impl IntoElement {
         let show_stats = AgentSettings::get_global(cx).show_turn_stats;
         let elapsed_label = show_stats
@@ -4942,7 +5366,7 @@ impl ThreadView {
                             let entity = entity.clone();
                             move |_, cx| {
                                 entity.update(cx, |this, cx| {
-                                    this.scroll_to_bottom(cx);
+                                    this.scroll_to_end(cx);
                                 });
                             }
                         })
@@ -5063,7 +5487,9 @@ impl ThreadView {
                         return false;
                     }
                 }
-                AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {}
+                AgentThreadEntry::ToolCall(_)
+                | AgentThreadEntry::AssistantMessage(_)
+                | AgentThreadEntry::CompletedPlan(_) => {}
             }
         }
 
@@ -7413,113 +7839,6 @@ impl ThreadView {
         }
     }
 
-    fn render_rules_item(&self, cx: &Context<Self>) -> Option<AnyElement> {
-        let project_context = self
-            .as_native_thread(cx)?
-            .read(cx)
-            .project_context()
-            .read(cx);
-
-        let user_rules_text = if project_context.user_rules.is_empty() {
-            None
-        } else if project_context.user_rules.len() == 1 {
-            let user_rules = &project_context.user_rules[0];
-
-            match user_rules.title.as_ref() {
-                Some(title) => Some(format!("Using \"{title}\" user rule")),
-                None => Some("Using user rule".into()),
-            }
-        } else {
-            Some(format!(
-                "Using {} user rules",
-                project_context.user_rules.len()
-            ))
-        };
-
-        let first_user_rules_id = project_context
-            .user_rules
-            .first()
-            .map(|user_rules| user_rules.uuid.0);
-
-        let rules_files = project_context
-            .worktrees
-            .iter()
-            .filter_map(|worktree| worktree.rules_file.as_ref())
-            .collect::<Vec<_>>();
-
-        let rules_file_text = match rules_files.as_slice() {
-            &[] => None,
-            &[rules_file] => Some(format!(
-                "Using project {:?} file",
-                rules_file.path_in_worktree
-            )),
-            rules_files => Some(format!("Using {} project rules files", rules_files.len())),
-        };
-
-        if user_rules_text.is_none() && rules_file_text.is_none() {
-            return None;
-        }
-
-        let has_both = user_rules_text.is_some() && rules_file_text.is_some();
-
-        Some(
-            h_flex()
-                .px_2p5()
-                .child(
-                    Icon::new(IconName::Attach)
-                        .size(IconSize::XSmall)
-                        .color(Color::Disabled),
-                )
-                .when_some(user_rules_text, |parent, user_rules_text| {
-                    parent.child(
-                        h_flex()
-                            .id("user-rules")
-                            .ml_1()
-                            .mr_1p5()
-                            .child(
-                                Label::new(user_rules_text)
-                                    .size(LabelSize::XSmall)
-                                    .color(Color::Muted)
-                                    .truncate(),
-                            )
-                            .hover(|s| s.bg(cx.theme().colors().element_hover))
-                            .tooltip(Tooltip::text("View User Rules"))
-                            .on_click(move |_event, window, cx| {
-                                window.dispatch_action(
-                                    Box::new(OpenRulesLibrary {
-                                        prompt_to_select: first_user_rules_id,
-                                    }),
-                                    cx,
-                                )
-                            }),
-                    )
-                })
-                .when(has_both, |this| {
-                    this.child(
-                        Label::new("β€’")
-                            .size(LabelSize::XSmall)
-                            .color(Color::Disabled),
-                    )
-                })
-                .when_some(rules_file_text, |parent, rules_file_text| {
-                    parent.child(
-                        h_flex()
-                            .id("project-rules")
-                            .ml_1p5()
-                            .child(
-                                Label::new(rules_file_text)
-                                    .size(LabelSize::XSmall)
-                                    .color(Color::Muted),
-                            )
-                            .hover(|s| s.bg(cx.theme().colors().element_hover))
-                            .tooltip(Tooltip::text("View Project Rules"))
-                            .on_click(cx.listener(Self::handle_open_rules)),
-                    )
-                })
-                .into_any(),
-        )
-    }
-
     fn tool_card_header_bg(&self, cx: &Context<Self>) -> Hsla {
         cx.theme()
             .colors()

crates/agent_ui/src/entry_view_state.rs πŸ”—

@@ -235,6 +235,11 @@ impl EntryViewState {
                 };
                 entry.sync(message);
             }
+            AgentThreadEntry::CompletedPlan(_) => {
+                if !matches!(self.entries.get(index), Some(Entry::CompletedPlan)) {
+                    self.set_entry(index, Entry::CompletedPlan);
+                }
+            }
         };
     }
 
@@ -253,7 +258,9 @@ impl EntryViewState {
     pub fn agent_ui_font_size_changed(&mut self, cx: &mut App) {
         for entry in self.entries.iter() {
             match entry {
-                Entry::UserMessage { .. } | Entry::AssistantMessage { .. } => {}
+                Entry::UserMessage { .. }
+                | Entry::AssistantMessage { .. }
+                | Entry::CompletedPlan => {}
                 Entry::ToolCall(ToolCallEntry { content }) => {
                     for view in content.values() {
                         if let Ok(diff_editor) = view.clone().downcast::<Editor>() {
@@ -320,6 +327,7 @@ pub enum Entry {
     UserMessage(Entity<MessageEditor>),
     AssistantMessage(AssistantMessageEntry),
     ToolCall(ToolCallEntry),
+    CompletedPlan,
 }
 
 impl Entry {
@@ -327,14 +335,14 @@ impl Entry {
         match self {
             Self::UserMessage(editor) => Some(editor.read(cx).focus_handle(cx)),
             Self::AssistantMessage(message) => Some(message.focus_handle.clone()),
-            Self::ToolCall(_) => None,
+            Self::ToolCall(_) | Self::CompletedPlan => None,
         }
     }
 
     pub fn message_editor(&self) -> Option<&Entity<MessageEditor>> {
         match self {
             Self::UserMessage(editor) => Some(editor),
-            Self::AssistantMessage(_) | Self::ToolCall(_) => None,
+            Self::AssistantMessage(_) | Self::ToolCall(_) | Self::CompletedPlan => None,
         }
     }
 
@@ -361,7 +369,7 @@ impl Entry {
     ) -> Option<ScrollHandle> {
         match self {
             Self::AssistantMessage(message) => message.scroll_handle_for_chunk(chunk_ix),
-            Self::UserMessage(_) | Self::ToolCall(_) => None,
+            Self::UserMessage(_) | Self::ToolCall(_) | Self::CompletedPlan => None,
         }
     }
 
@@ -376,7 +384,7 @@ impl Entry {
     pub fn has_content(&self) -> bool {
         match self {
             Self::ToolCall(ToolCallEntry { content }) => !content.is_empty(),
-            Self::UserMessage(_) | Self::AssistantMessage(_) => false,
+            Self::UserMessage(_) | Self::AssistantMessage(_) | Self::CompletedPlan => false,
         }
     }
 }

crates/agent_ui/src/threads_archive_view.rs πŸ”—

@@ -7,6 +7,7 @@ use crate::{
 use acp_thread::AgentSessionInfo;
 use agent::ThreadStore;
 use agent_client_protocol as acp;
+use agent_settings::AgentSettings;
 use chrono::{DateTime, Datelike as _, Local, NaiveDate, TimeDelta, Utc};
 use editor::Editor;
 use fs::Fs;
@@ -17,6 +18,7 @@ use gpui::{
 use itertools::Itertools as _;
 use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
 use project::{AgentId, AgentServerStore};
+use settings::Settings as _;
 use theme::ActiveTheme;
 use ui::{
     ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, Divider, HighlightedLabel,
@@ -795,7 +797,12 @@ impl ThreadsArchiveView {
 
     fn render_header(&self, window: &Window, cx: &mut Context<Self>) -> impl IntoElement {
         let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
-        let traffic_lights = cfg!(target_os = "macos") && !window.is_fullscreen();
+        let sidebar_on_left = matches!(
+            AgentSettings::get_global(cx).sidebar_side(),
+            settings::SidebarSide::Left
+        );
+        let traffic_lights =
+            cfg!(target_os = "macos") && !window.is_fullscreen() && sidebar_on_left;
         let header_height = platform_title_bar_height(window);
         let show_focus_keybinding =
             self.selection.is_some() && !self.filter_editor.focus_handle(cx).is_focused(window);
@@ -804,15 +811,21 @@ impl ThreadsArchiveView {
             .h(header_height)
             .mt_px()
             .pb_px()
-            .when(traffic_lights, |this| {
-                this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
+            .map(|this| {
+                if traffic_lights {
+                    this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
+                } else {
+                    this.pl_1p5()
+                }
             })
             .pr_1p5()
             .gap_1()
             .justify_between()
             .border_b_1()
             .border_color(cx.theme().colors().border)
-            .child(Divider::vertical().color(ui::DividerColor::Border))
+            .when(traffic_lights, |this| {
+                this.child(Divider::vertical().color(ui::DividerColor::Border))
+            })
             .child(
                 h_flex()
                     .ml_1()

crates/call/src/call_impl/mod.rs πŸ”—

@@ -17,8 +17,8 @@ use room::Event;
 use settings::Settings;
 use std::sync::Arc;
 use workspace::{
-    ActiveCallEvent, AnyActiveCall, GlobalAnyActiveCall, Pane, RemoteCollaborator, SharedScreen,
-    Workspace,
+    ActiveCallEvent, AnyActiveCall, GlobalAnyActiveCall, MultiWorkspace, MultiWorkspaceEvent, Pane,
+    RemoteCollaborator, SharedScreen, Workspace,
 };
 
 pub use livekit_client::{RemoteVideoTrack, RemoteVideoTrackView, RemoteVideoTrackViewEvent};
@@ -28,6 +28,36 @@ use crate::call_settings::CallSettings;
 
 pub fn init(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut App) {
     let active_call = cx.new(|cx| ActiveCall::new(client, user_store, cx));
+    let active_call_handle = active_call.downgrade();
+
+    cx.observe_new(move |_multi_workspace: &mut MultiWorkspace, window, cx| {
+        let Some(window) = window else {
+            return;
+        };
+
+        let active_call_handle = active_call_handle.clone();
+        cx.subscribe_in(
+            &cx.entity(),
+            window,
+            move |multi_workspace, _, event: &MultiWorkspaceEvent, window, cx| {
+                if !matches!(event, MultiWorkspaceEvent::ActiveWorkspaceChanged)
+                    && window.is_window_active()
+                {
+                    return;
+                }
+
+                let project = multi_workspace.workspace().read(cx).project().clone();
+                if let Ok(task) = active_call_handle.update(cx, |active_call, cx| {
+                    active_call.set_location(Some(&project), cx)
+                }) {
+                    task.detach_and_log_err(cx);
+                }
+            },
+        )
+        .detach();
+    })
+    .detach();
+
     cx.set_global(GlobalAnyActiveCall(Arc::new(ActiveCallEntity(active_call))))
 }
 

crates/collab_ui/src/collab_panel.rs πŸ”—

@@ -61,6 +61,8 @@ actions!(
         ///
         /// Use `collab::OpenChannelNotes` to open the channel notes for the current call.
         OpenSelectedChannelNotes,
+        /// Toggles whether the selected channel is in the Favorites section.
+        ToggleSelectedChannelFavorite,
         /// Starts moving a channel to a new location.
         StartMoveChannel,
         /// Moves the selected item to the current location.
@@ -256,6 +258,7 @@ pub struct CollabPanel {
     subscriptions: Vec<Subscription>,
     collapsed_sections: Vec<Section>,
     collapsed_channels: Vec<ChannelId>,
+    favorite_channels: Vec<ChannelId>,
     filter_active_channels: bool,
     workspace: WeakEntity<Workspace>,
 }
@@ -263,11 +266,14 @@ pub struct CollabPanel {
 #[derive(Serialize, Deserialize)]
 struct SerializedCollabPanel {
     collapsed_channels: Option<Vec<u64>>,
+    #[serde(default)]
+    favorite_channels: Option<Vec<u64>>,
 }
 
 #[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
 enum Section {
     ActiveCall,
+    FavoriteChannels,
     Channels,
     ChannelInvites,
     ContactRequests,
@@ -387,6 +393,7 @@ impl CollabPanel {
                 match_candidates: Vec::default(),
                 collapsed_sections: vec![Section::Offline],
                 collapsed_channels: Vec::default(),
+                favorite_channels: Vec::default(),
                 filter_active_channels: false,
                 workspace: workspace.weak_handle(),
                 client: workspace.app_state().client.clone(),
@@ -460,7 +467,13 @@ impl CollabPanel {
                 panel.update(cx, |panel, cx| {
                     panel.collapsed_channels = serialized_panel
                         .collapsed_channels
-                        .unwrap_or_else(Vec::new)
+                        .unwrap_or_default()
+                        .iter()
+                        .map(|cid| ChannelId(*cid))
+                        .collect();
+                    panel.favorite_channels = serialized_panel
+                        .favorite_channels
+                        .unwrap_or_default()
                         .iter()
                         .map(|cid| ChannelId(*cid))
                         .collect();
@@ -493,12 +506,22 @@ impl CollabPanel {
         } else {
             Some(self.collapsed_channels.iter().map(|id| id.0).collect())
         };
+
+        let favorite_channels = if self.favorite_channels.is_empty() {
+            None
+        } else {
+            Some(self.favorite_channels.iter().map(|id| id.0).collect())
+        };
+
         let kvp = KeyValueStore::global(cx);
         self.pending_serialization = cx.background_spawn(
             async move {
                 kvp.write_kvp(
                     serialization_key,
-                    serde_json::to_string(&SerializedCollabPanel { collapsed_channels })?,
+                    serde_json::to_string(&SerializedCollabPanel {
+                        collapsed_channels,
+                        favorite_channels,
+                    })?,
                 )
                 .await?;
                 anyhow::Ok(())
@@ -512,10 +535,8 @@ impl CollabPanel {
     }
 
     fn update_entries(&mut self, select_same_item: bool, cx: &mut Context<Self>) {
-        let channel_store = self.channel_store.read(cx);
-        let user_store = self.user_store.read(cx);
         let query = self.filter_editor.read(cx).text(cx);
-        let fg_executor = cx.foreground_executor();
+        let fg_executor = cx.foreground_executor().clone();
         let executor = cx.background_executor().clone();
 
         let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
@@ -541,7 +562,7 @@ impl CollabPanel {
                 }
 
                 // Populate the active user.
-                if let Some(user) = user_store.current_user() {
+                if let Some(user) = self.user_store.read(cx).current_user() {
                     self.match_candidates.clear();
                     self.match_candidates
                         .push(StringMatchCandidate::new(0, &user.github_login));
@@ -662,6 +683,64 @@ impl CollabPanel {
 
         let mut request_entries = Vec::new();
 
+        if self.channel_store.read(cx).channel_count() > 0 {
+            let previous_len = self.favorite_channels.len();
+            self.favorite_channels
+                .retain(|id| self.channel_store.read(cx).channel_for_id(*id).is_some());
+            if self.favorite_channels.len() != previous_len {
+                self.serialize(cx);
+            }
+        }
+
+        let channel_store = self.channel_store.read(cx);
+        let user_store = self.user_store.read(cx);
+
+        if !self.favorite_channels.is_empty() {
+            let favorite_channels: Vec<_> = self
+                .favorite_channels
+                .iter()
+                .filter_map(|id| channel_store.channel_for_id(*id))
+                .collect();
+
+            self.match_candidates.clear();
+            self.match_candidates.extend(
+                favorite_channels
+                    .iter()
+                    .enumerate()
+                    .map(|(ix, channel)| StringMatchCandidate::new(ix, &channel.name)),
+            );
+
+            let matches = fg_executor.block_on(match_strings(
+                &self.match_candidates,
+                &query,
+                true,
+                true,
+                usize::MAX,
+                &Default::default(),
+                executor.clone(),
+            ));
+
+            if !matches.is_empty() || query.is_empty() {
+                self.entries
+                    .push(ListEntry::Header(Section::FavoriteChannels));
+
+                let matches_by_candidate: HashMap<usize, &StringMatch> =
+                    matches.iter().map(|mat| (mat.candidate_id, mat)).collect();
+
+                for (ix, channel) in favorite_channels.iter().enumerate() {
+                    if !query.is_empty() && !matches_by_candidate.contains_key(&ix) {
+                        continue;
+                    }
+                    self.entries.push(ListEntry::Channel {
+                        channel: (*channel).clone(),
+                        depth: 0,
+                        has_children: false,
+                        string_match: matches_by_candidate.get(&ix).cloned().cloned(),
+                    });
+                }
+            }
+        }
+
         self.entries.push(ListEntry::Header(Section::Channels));
 
         if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() {
@@ -1359,6 +1438,18 @@ impl CollabPanel {
                     window.handler_for(&this, move |this, _, cx| {
                         this.copy_channel_notes_link(channel_id, cx)
                     }),
+                )
+                .separator()
+                .entry(
+                    if self.is_channel_favorited(channel_id) {
+                        "Remove from Favorites"
+                    } else {
+                        "Add to Favorites"
+                    },
+                    None,
+                    window.handler_for(&this, move |this, _window, cx| {
+                        this.toggle_favorite_channel(channel_id, cx)
+                    }),
                 );
 
             let mut has_destructive_actions = false;
@@ -1608,7 +1699,8 @@ impl CollabPanel {
                     Section::ActiveCall => Self::leave_call(window, cx),
                     Section::Channels => self.new_root_channel(window, cx),
                     Section::Contacts => self.toggle_contact_finder(window, cx),
-                    Section::ContactRequests
+                    Section::FavoriteChannels
+                    | Section::ContactRequests
                     | Section::Online
                     | Section::Offline
                     | Section::ChannelInvites => {
@@ -1838,6 +1930,24 @@ impl CollabPanel {
         self.collapsed_channels.binary_search(&channel_id).is_ok()
     }
 
+    fn toggle_favorite_channel(&mut self, channel_id: ChannelId, cx: &mut Context<Self>) {
+        match self.favorite_channels.binary_search(&channel_id) {
+            Ok(ix) => {
+                self.favorite_channels.remove(ix);
+            }
+            Err(ix) => {
+                self.favorite_channels.insert(ix, channel_id);
+            }
+        };
+        self.serialize(cx);
+        self.update_entries(true, cx);
+        cx.notify();
+    }
+
+    fn is_channel_favorited(&self, channel_id: ChannelId) -> bool {
+        self.favorite_channels.binary_search(&channel_id).is_ok()
+    }
+
     fn leave_call(window: &mut Window, cx: &mut App) {
         ActiveCall::global(cx)
             .update(cx, |call, cx| call.hang_up(cx))
@@ -1954,6 +2064,17 @@ impl CollabPanel {
         }
     }
 
+    fn toggle_selected_channel_favorite(
+        &mut self,
+        _: &ToggleSelectedChannelFavorite,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(channel) = self.selected_channel() {
+            self.toggle_favorite_channel(channel.id, cx);
+        }
+    }
+
     fn set_channel_visibility(
         &mut self,
         channel_id: ChannelId,
@@ -2340,46 +2461,57 @@ impl CollabPanel {
 
     fn render_signed_out(&mut self, cx: &mut Context<Self>) -> Div {
         let collab_blurb = "Work with your team in realtime with collaborative editing, voice, shared notes and more.";
-        let is_signing_in = self.client.status().borrow().is_signing_in();
-        let button_label = if is_signing_in {
-            "Signing in…"
+
+        // Two distinct "not connected" states:
+        //   - Authenticated (has credentials): user just needs to connect.
+        //   - Unauthenticated (no credentials): user needs to sign in via GitHub.
+        let is_authenticated = self.client.user_id().is_some();
+        let status = *self.client.status().borrow();
+        let is_busy = status.is_signing_in();
+
+        let (button_id, button_label, button_icon) = if is_authenticated {
+            (
+                "connect",
+                if is_busy { "Connecting…" } else { "Connect" },
+                IconName::Public,
+            )
         } else {
-            "Sign in"
+            (
+                "sign_in",
+                if is_busy {
+                    "Signing in…"
+                } else {
+                    "Sign In with GitHub"
+                },
+                IconName::Github,
+            )
         };
 
         v_flex()
-            .gap_6()
             .p_4()
+            .gap_4()
+            .size_full()
+            .text_center()
+            .justify_center()
             .child(Label::new(collab_blurb))
             .child(
-                v_flex()
-                    .gap_2()
-                    .child(
-                        Button::new("sign_in", button_label)
-                            .start_icon(Icon::new(IconName::Github).color(Color::Muted))
-                            .style(ButtonStyle::Filled)
-                            .full_width()
-                            .disabled(is_signing_in)
-                            .on_click(cx.listener(|this, _, window, cx| {
-                                let client = this.client.clone();
-                                let workspace = this.workspace.clone();
-                                cx.spawn_in(window, async move |_, mut cx| {
-                                    client
-                                        .connect(true, &mut cx)
-                                        .await
-                                        .into_response()
-                                        .notify_workspace_async_err(workspace, &mut cx);
-                                })
-                                .detach()
-                            })),
-                    )
-                    .child(
-                        v_flex().w_full().items_center().child(
-                            Label::new("Sign in to enable collaboration.")
-                                .color(Color::Muted)
-                                .size(LabelSize::Small),
-                        ),
-                    ),
+                Button::new(button_id, button_label)
+                    .full_width()
+                    .start_icon(Icon::new(button_icon).color(Color::Muted))
+                    .style(ButtonStyle::Outlined)
+                    .disabled(is_busy)
+                    .on_click(cx.listener(|this, _, window, cx| {
+                        let client = this.client.clone();
+                        let workspace = this.workspace.clone();
+                        cx.spawn_in(window, async move |_, mut cx| {
+                            client
+                                .connect(true, &mut cx)
+                                .await
+                                .into_response()
+                                .notify_workspace_async_err(workspace, &mut cx);
+                        })
+                        .detach()
+                    })),
             )
     }
 
@@ -2578,6 +2710,7 @@ impl CollabPanel {
                     SharedString::from("Current Call")
                 }
             }
+            Section::FavoriteChannels => SharedString::from("Favorites"),
             Section::ContactRequests => SharedString::from("Requests"),
             Section::Contacts => SharedString::from("Contacts"),
             Section::Channels => SharedString::from("Channels"),
@@ -2595,6 +2728,7 @@ impl CollabPanel {
             }),
             Section::Contacts => Some(
                 IconButton::new("add-contact", IconName::Plus)
+                    .icon_size(IconSize::Small)
                     .on_click(
                         cx.listener(|this, _, window, cx| this.toggle_contact_finder(window, cx)),
                     )
@@ -2608,9 +2742,6 @@ impl CollabPanel {
                             IconButton::new("filter-active-channels", IconName::ListFilter)
                                 .icon_size(IconSize::Small)
                                 .toggle_state(self.filter_active_channels)
-                                .when(!self.filter_active_channels, |button| {
-                                    button.visible_on_hover("section-header")
-                                })
                                 .on_click(cx.listener(|this, _, _window, cx| {
                                     this.filter_active_channels = !this.filter_active_channels;
                                     this.update_entries(true, cx);
@@ -2623,10 +2754,11 @@ impl CollabPanel {
                         )
                         .child(
                             IconButton::new("add-channel", IconName::Plus)
+                                .icon_size(IconSize::Small)
                                 .on_click(cx.listener(|this, _, window, cx| {
                                     this.new_root_channel(window, cx)
                                 }))
-                                .tooltip(Tooltip::text("Create a channel")),
+                                .tooltip(Tooltip::text("Create Channel")),
                         )
                         .into_any_element(),
                 )
@@ -2635,7 +2767,11 @@ impl CollabPanel {
         };
 
         let can_collapse = match section {
-            Section::ActiveCall | Section::Channels | Section::Contacts => false,
+            Section::ActiveCall
+            | Section::Channels
+            | Section::Contacts
+            | Section::FavoriteChannels => false,
+
             Section::ChannelInvites
             | Section::ContactRequests
             | Section::Online
@@ -2921,11 +3057,17 @@ impl CollabPanel {
             .unwrap_or(px(240.));
         let root_id = channel.root_id();
 
-        div()
-            .h_6()
+        let is_favorited = self.is_channel_favorited(channel_id);
+        let (favorite_icon, favorite_color, favorite_tooltip) = if is_favorited {
+            (IconName::StarFilled, Color::Accent, "Remove from Favorites")
+        } else {
+            (IconName::Star, Color::Muted, "Add to Favorites")
+        };
+
+        h_flex()
             .id(channel_id.0 as usize)
             .group("")
-            .flex()
+            .h_6()
             .w_full()
             .when(!channel.is_root_channel(), |el| {
                 el.on_drag(channel.clone(), move |channel, _, _, cx| {
@@ -2955,6 +3097,7 @@ impl CollabPanel {
             .child(
                 ListItem::new(channel_id.0 as usize)
                     // Add one level of depth for the disclosure arrow.
+                    .height(px(26.))
                     .indent_level(depth + 1)
                     .indent_step_size(px(20.))
                     .toggle_state(is_selected || is_active)
@@ -2980,78 +3123,105 @@ impl CollabPanel {
                             )
                         },
                     ))
-                    .start_slot(
-                        div()
-                            .relative()
-                            .child(
-                                Icon::new(if is_public {
-                                    IconName::Public
-                                } else {
-                                    IconName::Hash
-                                })
-                                .size(IconSize::Small)
-                                .color(Color::Muted),
-                            )
-                            .children(has_notes_notification.then(|| {
-                                div()
-                                    .w_1p5()
-                                    .absolute()
-                                    .right(px(-1.))
-                                    .top(px(-1.))
-                                    .child(Indicator::dot().color(Color::Info))
-                            })),
-                    )
                     .child(
                         h_flex()
-                            .id(channel_id.0 as usize)
-                            .child(match string_match {
-                                None => Label::new(channel.name.clone()).into_any_element(),
-                                Some(string_match) => HighlightedLabel::new(
-                                    channel.name.clone(),
-                                    string_match.positions.clone(),
-                                )
-                                .into_any_element(),
-                            })
-                            .children(face_pile.map(|face_pile| face_pile.p_1())),
+                            .id(format!("inside-{}", channel_id.0))
+                            .w_full()
+                            .gap_1()
+                            .child(
+                                div()
+                                    .relative()
+                                    .child(
+                                        Icon::new(if is_public {
+                                            IconName::Public
+                                        } else {
+                                            IconName::Hash
+                                        })
+                                        .size(IconSize::Small)
+                                        .color(Color::Muted),
+                                    )
+                                    .children(has_notes_notification.then(|| {
+                                        div()
+                                            .w_1p5()
+                                            .absolute()
+                                            .right(px(-1.))
+                                            .top(px(-1.))
+                                            .child(Indicator::dot().color(Color::Info))
+                                    })),
+                            )
+                            .child(
+                                h_flex()
+                                    .id(channel_id.0 as usize)
+                                    .child(match string_match {
+                                        None => Label::new(channel.name.clone()).into_any_element(),
+                                        Some(string_match) => HighlightedLabel::new(
+                                            channel.name.clone(),
+                                            string_match.positions.clone(),
+                                        )
+                                        .into_any_element(),
+                                    })
+                                    .children(face_pile.map(|face_pile| face_pile.p_1())),
+                            )
+                            .tooltip({
+                                let channel_store = self.channel_store.clone();
+                                move |_window, cx| {
+                                    cx.new(|_| JoinChannelTooltip {
+                                        channel_store: channel_store.clone(),
+                                        channel_id,
+                                        has_notes_notification,
+                                    })
+                                    .into()
+                                }
+                            }),
                     ),
             )
             .child(
-                h_flex().absolute().right(rems(0.)).h_full().child(
-                    h_flex()
-                        .h_full()
-                        .bg(cx.theme().colors().background)
-                        .rounded_l_sm()
-                        .gap_1()
-                        .px_1()
-                        .child(
-                            IconButton::new("channel_notes", IconName::Reader)
-                                .style(ButtonStyle::Filled)
-                                .shape(ui::IconButtonShape::Square)
-                                .icon_size(IconSize::Small)
-                                .icon_color(if has_notes_notification {
-                                    Color::Default
-                                } else {
-                                    Color::Muted
-                                })
-                                .on_click(cx.listener(move |this, _, window, cx| {
-                                    this.open_channel_notes(channel_id, window, cx)
-                                }))
-                                .tooltip(Tooltip::text("Open channel notes")),
-                        )
-                        .visible_on_hover(""),
-                ),
-            )
-            .tooltip({
-                let channel_store = self.channel_store.clone();
-                move |_window, cx| {
-                    cx.new(|_| JoinChannelTooltip {
-                        channel_store: channel_store.clone(),
-                        channel_id,
-                        has_notes_notification,
+                h_flex()
+                    .absolute()
+                    .right_0()
+                    .visible_on_hover("")
+                    .h_full()
+                    .pl_1()
+                    .pr_1p5()
+                    .gap_0p5()
+                    .bg(cx.theme().colors().background.opacity(0.5))
+                    .child({
+                        let focus_handle = self.focus_handle.clone();
+                        IconButton::new("channel_favorite", favorite_icon)
+                            .icon_size(IconSize::Small)
+                            .icon_color(favorite_color)
+                            .on_click(cx.listener(move |this, _, _window, cx| {
+                                this.toggle_favorite_channel(channel_id, cx)
+                            }))
+                            .tooltip(move |_window, cx| {
+                                Tooltip::for_action_in(
+                                    favorite_tooltip,
+                                    &ToggleSelectedChannelFavorite,
+                                    &focus_handle,
+                                    cx,
+                                )
+                            })
                     })
-                    .into()
-                }
-            })
+                    .child({
+                        let focus_handle = self.focus_handle.clone();
+                        IconButton::new("channel_notes", IconName::Reader)
+                            .icon_size(IconSize::Small)
+                            .when(!has_notes_notification, |this| {
+                                this.icon_color(Color::Muted)
+                            })
+                            .on_click(cx.listener(move |this, _, window, cx| {
+                                this.open_channel_notes(channel_id, window, cx)
+                            }))
+                            .tooltip(move |_window, cx| {
+                                Tooltip::for_action_in(
+                                    "Open Channel Notes",
+                                    &OpenSelectedChannelNotes,
+                                    &focus_handle,
+                                    cx,
+                                )
+                            })
+                    }),
+            )
     }
 
     fn render_channel_editor(
@@ -3150,6 +3320,7 @@ impl Render for CollabPanel {
             .on_action(cx.listener(CollabPanel::show_inline_context_menu))
             .on_action(cx.listener(CollabPanel::rename_selected_channel))
             .on_action(cx.listener(CollabPanel::open_selected_channel_notes))
+            .on_action(cx.listener(CollabPanel::toggle_selected_channel_favorite))
             .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))
@@ -3371,7 +3542,7 @@ impl Render for JoinChannelTooltip {
                 .channel_participants(self.channel_id);
 
             container
-                .child(Label::new("Join channel"))
+                .child(Label::new("Join Channel"))
                 .children(participants.iter().map(|participant| {
                     h_flex()
                         .gap_2()

crates/debugger_ui/src/session/running/variable_list.rs πŸ”—

@@ -1076,7 +1076,12 @@ impl VariableList {
         presentation_hint: Option<&VariablePresentationHint>,
         cx: &Context<Self>,
     ) -> VariableColor {
-        let syntax_color_for = |name| cx.theme().syntax().get(name).color;
+        let syntax_color_for = |name| {
+            cx.theme()
+                .syntax()
+                .style_for_name(name)
+                .and_then(|style| style.color)
+        };
         let name = if self.disabled {
             Some(Color::Disabled.color(cx))
         } else {

crates/debugger_ui/src/tests/inline_values.rs πŸ”—

@@ -1826,7 +1826,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");
+    let debug_variables_query = include_str!("../../../grammars/src/python/debugger.scm");
     Language::new(
         LanguageConfig {
             name: "Python".into(),
@@ -1843,7 +1843,7 @@ fn python_lang() -> Language {
 }
 
 fn go_lang() -> Arc<Language> {
-    let debug_variables_query = include_str!("../../../languages/src/go/debugger.scm");
+    let debug_variables_query = include_str!("../../../grammars/src/go/debugger.scm");
     Arc::new(
         Language::new(
             LanguageConfig {
@@ -2262,7 +2262,7 @@ fn main() {
 }
 
 fn javascript_lang() -> Arc<Language> {
-    let debug_variables_query = include_str!("../../../languages/src/javascript/debugger.scm");
+    let debug_variables_query = include_str!("../../../grammars/src/javascript/debugger.scm");
     Arc::new(
         Language::new(
             LanguageConfig {
@@ -2281,7 +2281,7 @@ fn javascript_lang() -> Arc<Language> {
 }
 
 fn typescript_lang() -> Arc<Language> {
-    let debug_variables_query = include_str!("../../../languages/src/typescript/debugger.scm");
+    let debug_variables_query = include_str!("../../../grammars/src/typescript/debugger.scm");
     Arc::new(
         Language::new(
             LanguageConfig {
@@ -2300,7 +2300,7 @@ fn typescript_lang() -> Arc<Language> {
 }
 
 fn tsx_lang() -> Arc<Language> {
-    let debug_variables_query = include_str!("../../../languages/src/tsx/debugger.scm");
+    let debug_variables_query = include_str!("../../../grammars/src/tsx/debugger.scm");
     Arc::new(
         Language::new(
             LanguageConfig {

crates/docs_preprocessor/src/main.rs πŸ”—

@@ -22,8 +22,45 @@ static KEYMAP_WINDOWS: LazyLock<KeymapFile> = LazyLock::new(|| {
     load_keymap("keymaps/default-windows.json").expect("Failed to load Windows keymap")
 });
 
+static KEYMAP_JETBRAINS_MACOS: LazyLock<KeymapFile> = LazyLock::new(|| {
+    load_keymap("keymaps/macos/jetbrains.json").expect("Failed to load JetBrains macOS keymap")
+});
+
+static KEYMAP_JETBRAINS_LINUX: LazyLock<KeymapFile> = LazyLock::new(|| {
+    load_keymap("keymaps/linux/jetbrains.json").expect("Failed to load JetBrains Linux keymap")
+});
+
 static ALL_ACTIONS: LazyLock<ActionManifest> = LazyLock::new(load_all_actions);
 
+#[derive(Clone, Copy)]
+#[allow(dead_code)]
+enum Os {
+    MacOs,
+    Linux,
+    Windows,
+}
+
+#[derive(Clone, Copy)]
+enum KeymapOverlay {
+    JetBrains,
+}
+
+impl KeymapOverlay {
+    fn parse(name: &str) -> Option<Self> {
+        match name {
+            "jetbrains" => Some(Self::JetBrains),
+            _ => None,
+        }
+    }
+
+    fn keymap(self, os: Os) -> &'static KeymapFile {
+        match (self, os) {
+            (Self::JetBrains, Os::MacOs) => &KEYMAP_JETBRAINS_MACOS,
+            (Self::JetBrains, Os::Linux | Os::Windows) => &KEYMAP_JETBRAINS_LINUX,
+        }
+    }
+}
+
 const FRONT_MATTER_COMMENT: &str = "<!-- ZED_META {} -->";
 
 fn main() -> Result<()> {
@@ -64,6 +101,9 @@ enum PreprocessorError {
         snippet: String,
         error: String,
     },
+    UnknownKeymapOverlay {
+        overlay_name: String,
+    },
 }
 
 impl PreprocessorError {
@@ -125,6 +165,13 @@ impl std::fmt::Display for PreprocessorError {
                     snippet
                 )
             }
+            PreprocessorError::UnknownKeymapOverlay { overlay_name } => {
+                write!(
+                    f,
+                    "Unknown keymap overlay: '{}'. Supported overlays: jetbrains",
+                    overlay_name
+                )
+            }
         }
     }
 }
@@ -205,20 +252,39 @@ fn format_binding(binding: String) -> String {
 }
 
 fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
-    let regex = Regex::new(r"\{#kb (.*?)\}").unwrap();
+    let regex = Regex::new(r"\{#kb(?::(\w+))?\s+(.*?)\}").unwrap();
 
     for_each_chapter_mut(book, |chapter| {
         chapter.content = regex
             .replace_all(&chapter.content, |caps: &regex::Captures| {
-                let action = caps[1].trim();
+                let overlay_name = caps.get(1).map(|m| m.as_str());
+                let action = caps[2].trim();
+
                 if is_missing_action(action) {
                     errors.insert(PreprocessorError::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();
+
+                let overlay = if let Some(name) = overlay_name {
+                    let Some(overlay) = KeymapOverlay::parse(name) else {
+                        errors.insert(PreprocessorError::UnknownKeymapOverlay {
+                            overlay_name: name.to_string(),
+                        });
+                        return String::new();
+                    };
+                    Some(overlay)
+                } else {
+                    None
+                };
+
+                let macos_binding =
+                    find_binding_with_overlay(Os::MacOs, action, overlay)
+                        .unwrap_or_default();
+                let linux_binding =
+                    find_binding_with_overlay(Os::Linux, action, overlay)
+                        .unwrap_or_default();
 
                 if macos_binding.is_empty() && linux_binding.is_empty() {
                     return "<div>No default binding</div>".to_string();
@@ -227,7 +293,7 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<Prepr
                 let formatted_macos_binding = format_binding(macos_binding);
                 let formatted_linux_binding = format_binding(linux_binding);
 
-                format!("<kbd class=\"keybinding\">{formatted_macos_binding}|{formatted_linux_binding}</kbd>")
+                format!("<kbd class=\"keybinding\">{formatted_macos_binding}&#124;{formatted_linux_binding}</kbd>")
             })
             .into_owned()
     });
@@ -270,15 +336,8 @@ fn is_missing_action(name: &str) -> bool {
     actions_available() && find_action_by_name(name).is_none()
 }
 
-fn find_binding(os: &str, action: &str) -> Option<String> {
-    let keymap = match os {
-        "macos" => &KEYMAP_MACOS,
-        "linux" | "freebsd" => &KEYMAP_LINUX,
-        "windows" => &KEYMAP_WINDOWS,
-        _ => unreachable!("Not a valid OS: {}", os),
-    };
-
-    // Find the binding in reverse order, as the last binding takes precedence.
+// Find the binding in reverse order, as the last binding takes precedence.
+fn find_binding_in_keymap(keymap: &KeymapFile, action: &str) -> Option<String> {
     keymap.sections().rev().find_map(|section| {
         section.bindings().rev().find_map(|(keystroke, a)| {
             if name_for_action(a.to_string()) == action {
@@ -290,6 +349,25 @@ fn find_binding(os: &str, action: &str) -> Option<String> {
     })
 }
 
+fn find_binding(os: Os, action: &str) -> Option<String> {
+    let keymap = match os {
+        Os::MacOs => &KEYMAP_MACOS,
+        Os::Linux => &KEYMAP_LINUX,
+        Os::Windows => &KEYMAP_WINDOWS,
+    };
+    find_binding_in_keymap(keymap, action)
+}
+
+fn find_binding_with_overlay(
+    os: Os,
+    action: &str,
+    overlay: Option<KeymapOverlay>,
+) -> Option<String> {
+    overlay
+        .and_then(|overlay| find_binding_in_keymap(overlay.keymap(os), action))
+        .or_else(|| find_binding(os, action))
+}
+
 fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
     let settings_schema = SettingsStore::json_schema(&Default::default());
     let settings_validator = jsonschema::validator_for(&settings_schema)

crates/edit_prediction/Cargo.toml πŸ”—

@@ -18,7 +18,6 @@ cli-support = []
 ai_onboarding.workspace = true
 anyhow.workspace = true
 heapless.workspace = true
-brotli.workspace = true
 buffer_diff.workspace = true
 client.workspace = true
 clock.workspace = true

crates/edit_prediction/src/edit_prediction.rs πŸ”—

@@ -50,7 +50,8 @@ use std::path::Path;
 use std::rc::Rc;
 use std::str::FromStr as _;
 use std::sync::Arc;
-use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+use std::time::{Duration, Instant};
+
 use thiserror::Error;
 use util::{RangeExt as _, ResultExt as _};
 
@@ -63,7 +64,6 @@ pub mod ollama;
 mod onboarding_modal;
 pub mod open_ai_response;
 mod prediction;
-pub mod sweep_ai;
 
 pub mod udiff;
 
@@ -83,7 +83,6 @@ use crate::onboarding_modal::ZedPredictModal;
 pub use crate::prediction::EditPrediction;
 pub use crate::prediction::EditPredictionId;
 use crate::prediction::EditPredictionResult;
-pub use crate::sweep_ai::SweepAi;
 pub use capture_example::capture_example;
 pub use language_model::ApiKeyState;
 pub use telemetry_events::EditPredictionRating;
@@ -143,7 +142,6 @@ pub struct EditPredictionStore {
     zeta2_raw_config: Option<Zeta2RawConfig>,
     preferred_experiment: Option<String>,
     available_experiments: Vec<String>,
-    pub sweep_ai: SweepAi,
     pub mercury: Mercury,
     data_collection_choice: DataCollectionChoice,
     reject_predictions_tx: mpsc::UnboundedSender<EditPredictionRejectionPayload>,
@@ -163,7 +161,6 @@ pub(crate) struct EditPredictionRejectionPayload {
 pub enum EditPredictionModel {
     Zeta,
     Fim { format: EditPredictionPromptFormat },
-    Sweep,
     Mercury,
 }
 
@@ -175,13 +172,11 @@ pub struct EditPredictionModelInput {
     position: Anchor,
     events: Vec<Arc<zeta_prompt::Event>>,
     related_files: Vec<RelatedFile>,
-    recent_paths: VecDeque<ProjectPath>,
     trigger: PredictEditsRequestTrigger,
     diagnostic_search_range: Range<Point>,
     debug_tx: Option<mpsc::UnboundedSender<DebugEvent>>,
     can_collect_data: bool,
     is_open_source: bool,
-    pub user_actions: Vec<UserActionRecord>,
 }
 
 #[derive(Debug)]
@@ -220,26 +215,6 @@ pub struct EditPredictionFinishedDebugEvent {
     pub model_output: Option<String>,
 }
 
-const USER_ACTION_HISTORY_SIZE: usize = 16;
-
-#[derive(Clone, Debug)]
-pub struct UserActionRecord {
-    pub action_type: UserActionType,
-    pub buffer_id: EntityId,
-    pub line_number: u32,
-    pub offset: usize,
-    pub timestamp_epoch_ms: u64,
-}
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum UserActionType {
-    InsertChar,
-    InsertSelection,
-    DeleteChar,
-    DeleteSelection,
-    CursorMovement,
-}
-
 /// An event with associated metadata for reconstructing buffer state.
 #[derive(Clone)]
 pub struct StoredEvent {
@@ -339,19 +314,11 @@ struct ProjectState {
     cancelled_predictions: HashSet<usize>,
     context: Entity<RelatedExcerptStore>,
     license_detection_watchers: HashMap<WorktreeId, Rc<LicenseDetectionWatcher>>,
-    user_actions: VecDeque<UserActionRecord>,
     _subscriptions: [gpui::Subscription; 2],
     copilot: Option<Entity<Copilot>>,
 }
 
 impl ProjectState {
-    fn record_user_action(&mut self, action: UserActionRecord) {
-        if self.user_actions.len() >= USER_ACTION_HISTORY_SIZE {
-            self.user_actions.pop_front();
-        }
-        self.user_actions.push_back(action);
-    }
-
     pub fn events(&self, cx: &App) -> Vec<StoredEvent> {
         self.events
             .iter()
@@ -828,7 +795,6 @@ impl EditPredictionStore {
             zeta2_raw_config: Self::zeta2_raw_config_from_env(),
             preferred_experiment: None,
             available_experiments: Vec::new(),
-            sweep_ai: SweepAi::new(cx),
             mercury: Mercury::new(cx),
 
             data_collection_choice,
@@ -939,13 +905,6 @@ impl EditPredictionStore {
     pub fn icons(&self, cx: &App) -> edit_prediction_types::EditPredictionIconSet {
         use ui::IconName;
         match self.edit_prediction_model {
-            EditPredictionModel::Sweep => {
-                edit_prediction_types::EditPredictionIconSet::new(IconName::SweepAi)
-                    .with_disabled(IconName::SweepAiDisabled)
-                    .with_up(IconName::SweepAiUp)
-                    .with_down(IconName::SweepAiDown)
-                    .with_error(IconName::SweepAiError)
-            }
             EditPredictionModel::Mercury => {
                 edit_prediction_types::EditPredictionIconSet::new(IconName::Inception)
             }
@@ -970,10 +929,6 @@ impl EditPredictionStore {
         }
     }
 
-    pub fn has_sweep_api_token(&self, cx: &App) -> bool {
-        self.sweep_ai.api_token.read(cx).has_key()
-    }
-
     pub fn has_mercury_api_token(&self, cx: &App) -> bool {
         self.mercury.api_token.read(cx).has_key()
     }
@@ -1132,7 +1087,6 @@ impl EditPredictionStore {
                 last_edit_prediction_refresh: None,
                 last_jump_prediction_refresh: None,
                 license_detection_watchers: HashMap::default(),
-                user_actions: VecDeque::with_capacity(USER_ACTION_HISTORY_SIZE),
                 _subscriptions: [
                     cx.subscribe(&project, Self::handle_project_event),
                     cx.observe_release(&project, move |this, _, cx| {
@@ -1347,24 +1301,16 @@ impl EditPredictionStore {
         }
         let old_file = mem::replace(&mut registered_buffer.file, new_file.clone());
         let old_snapshot = mem::replace(&mut registered_buffer.snapshot, new_snapshot.clone());
-        let mut num_edits = 0usize;
-        let mut total_deleted = 0usize;
-        let mut total_inserted = 0usize;
         let mut edit_range: Option<Range<Anchor>> = None;
-        let mut last_offset: Option<usize> = None;
         let now = cx.background_executor().now();
 
-        for (edit, anchor_range) in
+        for (_edit, anchor_range) in
             new_snapshot.anchored_edits_since::<usize>(&old_snapshot.version)
         {
-            num_edits += 1;
-            total_deleted += edit.old.len();
-            total_inserted += edit.new.len();
             edit_range = Some(match edit_range {
                 None => anchor_range,
                 Some(acc) => acc.start..anchor_range.end,
             });
-            last_offset = Some(edit.new.end);
         }
 
         let Some(edit_range) = edit_range else {
@@ -1387,32 +1333,6 @@ impl EditPredictionStore {
                 cx,
             );
 
-        if is_local {
-            let action_type = match (total_deleted, total_inserted, num_edits) {
-                (0, ins, n) if ins == n => UserActionType::InsertChar,
-                (0, _, _) => UserActionType::InsertSelection,
-                (del, 0, n) if del == n => UserActionType::DeleteChar,
-                (_, 0, _) => UserActionType::DeleteSelection,
-                (_, ins, n) if ins == n => UserActionType::InsertChar,
-                (_, _, _) => UserActionType::InsertSelection,
-            };
-
-            if let Some(offset) = last_offset {
-                let point = new_snapshot.offset_to_point(offset);
-                let timestamp_epoch_ms = SystemTime::now()
-                    .duration_since(UNIX_EPOCH)
-                    .map(|d| d.as_millis() as u64)
-                    .unwrap_or(0);
-                project_state.record_user_action(UserActionRecord {
-                    action_type,
-                    buffer_id: buffer.entity_id(),
-                    line_number: point.row,
-                    offset,
-                    timestamp_epoch_ms,
-                });
-            }
-        }
-
         if !include_in_history {
             return;
         }
@@ -1562,9 +1482,6 @@ impl EditPredictionStore {
         }
 
         match self.edit_prediction_model {
-            EditPredictionModel::Sweep => {
-                sweep_ai::edit_prediction_accepted(self, current_prediction, cx)
-            }
             EditPredictionModel::Mercury => {
                 mercury::edit_prediction_accepted(
                     current_prediction.prediction.id,
@@ -1792,7 +1709,7 @@ impl EditPredictionStore {
         &mut self,
         project: &Entity<Project>,
         display_type: edit_prediction_types::SuggestionDisplayType,
-        cx: &mut Context<Self>,
+        _cx: &mut Context<Self>,
     ) {
         let Some(project_state) = self.projects.get_mut(&project.entity_id()) else {
             return;
@@ -1815,18 +1732,6 @@ impl EditPredictionStore {
             current_prediction.was_shown = true;
         }
 
-        let display_type_changed = previous_shown_with != Some(display_type);
-
-        if self.edit_prediction_model == EditPredictionModel::Sweep && display_type_changed {
-            sweep_ai::edit_prediction_shown(
-                &self.sweep_ai,
-                self.client.clone(),
-                &current_prediction.prediction,
-                display_type,
-                cx,
-            );
-        }
-
         if is_first_non_jump_show {
             self.shown_predictions
                 .push_front(current_prediction.prediction.clone());
@@ -1883,7 +1788,7 @@ impl EditPredictionStore {
                     cx,
                 );
             }
-            EditPredictionModel::Sweep | EditPredictionModel::Fim { .. } => {}
+            EditPredictionModel::Fim { .. } => {}
         }
     }
 
@@ -2108,7 +2013,6 @@ fn currently_following(project: &Entity<Project>, cx: &App) -> bool {
 fn is_ep_store_provider(provider: EditPredictionProvider) -> bool {
     match provider {
         EditPredictionProvider::Zed
-        | EditPredictionProvider::Sweep
         | EditPredictionProvider::Mercury
         | EditPredictionProvider::Ollama
         | EditPredictionProvider::OpenAiCompatibleApi
@@ -2148,7 +2052,6 @@ impl EditPredictionStore {
         let (needs_acceptance_tracking, max_pending_predictions) =
             match all_language_settings(None, cx).edit_predictions.provider {
                 EditPredictionProvider::Zed
-                | EditPredictionProvider::Sweep
                 | EditPredictionProvider::Mercury
                 | EditPredictionProvider::Experimental(_) => (true, 2),
                 EditPredictionProvider::Ollama => (false, 1),
@@ -2370,28 +2273,6 @@ impl EditPredictionStore {
 
         let snapshot = active_buffer.read(cx).snapshot();
         let cursor_point = position.to_point(&snapshot);
-        let current_offset = position.to_offset(&snapshot);
-
-        let mut user_actions: Vec<UserActionRecord> =
-            project_state.user_actions.iter().cloned().collect();
-
-        if let Some(last_action) = user_actions.last() {
-            if last_action.buffer_id == active_buffer.entity_id()
-                && current_offset != last_action.offset
-            {
-                let timestamp_epoch_ms = SystemTime::now()
-                    .duration_since(UNIX_EPOCH)
-                    .map(|d| d.as_millis() as u64)
-                    .unwrap_or(0);
-                user_actions.push(UserActionRecord {
-                    action_type: UserActionType::CursorMovement,
-                    buffer_id: active_buffer.entity_id(),
-                    line_number: cursor_point.row,
-                    offset: current_offset,
-                    timestamp_epoch_ms,
-                });
-            }
-        }
         let diagnostic_search_start = cursor_point.row.saturating_sub(DIAGNOSTIC_LINES_RANGE);
         let diagnostic_search_end = cursor_point.row + DIAGNOSTIC_LINES_RANGE;
         let diagnostic_search_range =
@@ -2410,8 +2291,6 @@ impl EditPredictionStore {
             && self.is_data_collection_enabled(cx)
             && matches!(self.edit_prediction_model, EditPredictionModel::Zeta);
 
-        let recent_paths = project_state.recent_paths.clone();
-
         let inputs = EditPredictionModelInput {
             project: project.clone(),
             buffer: active_buffer,
@@ -2419,11 +2298,9 @@ impl EditPredictionStore {
             position,
             events,
             related_files,
-            recent_paths,
             trigger,
             diagnostic_search_range: diagnostic_search_range,
             debug_tx,
-            user_actions,
             can_collect_data,
             is_open_source,
         };
@@ -2435,7 +2312,6 @@ impl EditPredictionStore {
                 zeta::request_prediction_with_zeta(self, inputs, capture_data, cx)
             }
             EditPredictionModel::Fim { format } => fim::request_prediction(inputs, format, cx),
-            EditPredictionModel::Sweep => self.sweep_ai.request_prediction_with_sweep(inputs, cx),
             EditPredictionModel::Mercury => self.mercury.request_prediction(inputs, cx),
         };
 

crates/edit_prediction/src/sweep_ai.rs πŸ”—

@@ -1,669 +0,0 @@
-use crate::{
-    CurrentEditPrediction, DebugEvent, EditPrediction, EditPredictionFinishedDebugEvent,
-    EditPredictionId, EditPredictionModelInput, EditPredictionStartedDebugEvent,
-    EditPredictionStore, UserActionRecord, UserActionType, prediction::EditPredictionResult,
-};
-use anyhow::{Result, bail};
-use client::Client;
-use edit_prediction_types::SuggestionDisplayType;
-use futures::{AsyncReadExt as _, channel::mpsc};
-use gpui::{
-    App, AppContext as _, Entity, Global, SharedString, Task,
-    http_client::{self, AsyncBody, Method},
-};
-use language::language_settings::all_language_settings;
-use language::{Anchor, Buffer, BufferSnapshot, Point, ToOffset as _};
-use language_model::{ApiKeyState, EnvVar, env_var};
-use lsp::DiagnosticSeverity;
-use serde::{Deserialize, Serialize};
-use std::{
-    fmt::{self, Write as _},
-    ops::Range,
-    path::Path,
-    sync::Arc,
-};
-
-const SWEEP_API_URL: &str = "https://autocomplete.sweep.dev/backend/next_edit_autocomplete";
-const SWEEP_METRICS_URL: &str = "https://backend.app.sweep.dev/backend/track_autocomplete_metrics";
-
-pub struct SweepAi {
-    pub api_token: Entity<ApiKeyState>,
-    pub debug_info: Arc<str>,
-}
-
-impl SweepAi {
-    pub fn new(cx: &mut App) -> Self {
-        SweepAi {
-            api_token: sweep_api_token(cx),
-            debug_info: debug_info(cx),
-        }
-    }
-
-    pub fn request_prediction_with_sweep(
-        &self,
-        inputs: EditPredictionModelInput,
-        cx: &mut App,
-    ) -> Task<Result<Option<EditPredictionResult>>> {
-        let privacy_mode_enabled = all_language_settings(None, cx)
-            .edit_predictions
-            .sweep
-            .privacy_mode;
-        let debug_info = self.debug_info.clone();
-        let request_start = cx.background_executor().now();
-        self.api_token.update(cx, |key_state, cx| {
-            _ = key_state.load_if_needed(SWEEP_CREDENTIALS_URL, |s| s, cx);
-        });
-
-        let buffer = inputs.buffer.clone();
-        let debug_tx = inputs.debug_tx.clone();
-
-        let Some(api_token) = self.api_token.read(cx).key(&SWEEP_CREDENTIALS_URL) else {
-            return Task::ready(Ok(None));
-        };
-        let full_path: Arc<Path> = inputs
-            .snapshot
-            .file()
-            .map(|file| file.full_path(cx))
-            .unwrap_or_else(|| "untitled".into())
-            .into();
-
-        let project_file = project::File::from_dyn(inputs.snapshot.file());
-        let repo_name = project_file
-            .map(|file| file.worktree.read(cx).root_name_str())
-            .unwrap_or("untitled")
-            .into();
-        let offset = inputs.position.to_offset(&inputs.snapshot);
-        let buffer_entity_id = inputs.buffer.entity_id();
-
-        let recent_buffers = inputs.recent_paths.iter().cloned();
-        let http_client = cx.http_client();
-
-        let recent_buffer_snapshots = recent_buffers
-            .filter_map(|project_path| {
-                let buffer = inputs.project.read(cx).get_open_buffer(&project_path, cx)?;
-                if inputs.buffer == buffer {
-                    None
-                } else {
-                    Some(buffer.read(cx).snapshot())
-                }
-            })
-            .take(3)
-            .collect::<Vec<_>>();
-
-        let result = cx.background_spawn(async move {
-            let text = inputs.snapshot.text();
-
-            let mut recent_changes = String::new();
-            for event in &inputs.events {
-                write_event(event.as_ref(), &mut recent_changes).unwrap();
-            }
-
-            let file_chunks = recent_buffer_snapshots
-                .into_iter()
-                .map(|snapshot| {
-                    let end_point = Point::new(30, 0).min(snapshot.max_point());
-                    FileChunk {
-                        content: snapshot.text_for_range(Point::zero()..end_point).collect(),
-                        file_path: snapshot
-                            .file()
-                            .map(|f| f.path().as_unix_str())
-                            .unwrap_or("untitled")
-                            .to_string(),
-                        start_line: 0,
-                        end_line: end_point.row as usize,
-                        timestamp: snapshot.file().and_then(|file| {
-                            Some(
-                                file.disk_state()
-                                    .mtime()?
-                                    .to_seconds_and_nanos_for_persistence()?
-                                    .0,
-                            )
-                        }),
-                    }
-                })
-                .collect::<Vec<_>>();
-
-            let mut retrieval_chunks: Vec<FileChunk> = inputs
-                .related_files
-                .iter()
-                .flat_map(|related_file| {
-                    related_file.excerpts.iter().map(|excerpt| FileChunk {
-                        file_path: related_file.path.to_string_lossy().to_string(),
-                        start_line: excerpt.row_range.start as usize,
-                        end_line: excerpt.row_range.end as usize,
-                        content: excerpt.text.to_string(),
-                        timestamp: None,
-                    })
-                })
-                .collect();
-
-            let diagnostic_entries = inputs
-                .snapshot
-                .diagnostics_in_range(inputs.diagnostic_search_range, false);
-            let mut diagnostic_content = String::new();
-            let mut diagnostic_count = 0;
-
-            for entry in diagnostic_entries {
-                let start_point: Point = entry.range.start;
-
-                let severity = match entry.diagnostic.severity {
-                    DiagnosticSeverity::ERROR => "error",
-                    DiagnosticSeverity::WARNING => "warning",
-                    DiagnosticSeverity::INFORMATION => "info",
-                    DiagnosticSeverity::HINT => "hint",
-                    _ => continue,
-                };
-
-                diagnostic_count += 1;
-
-                writeln!(
-                    &mut diagnostic_content,
-                    "{}:{}:{}: {}: {}",
-                    full_path.display(),
-                    start_point.row + 1,
-                    start_point.column + 1,
-                    severity,
-                    entry.diagnostic.message
-                )?;
-            }
-
-            if !diagnostic_content.is_empty() {
-                retrieval_chunks.push(FileChunk {
-                    file_path: "diagnostics".to_string(),
-                    start_line: 1,
-                    end_line: diagnostic_count,
-                    content: diagnostic_content,
-                    timestamp: None,
-                });
-            }
-
-            let file_path_str = full_path.display().to_string();
-            let recent_user_actions = inputs
-                .user_actions
-                .iter()
-                .filter(|r| r.buffer_id == buffer_entity_id)
-                .map(|r| to_sweep_user_action(r, &file_path_str))
-                .collect();
-
-            let request_body = AutocompleteRequest {
-                debug_info,
-                repo_name,
-                file_path: full_path.clone(),
-                file_contents: text.clone(),
-                original_file_contents: text,
-                cursor_position: offset,
-                recent_changes: recent_changes.clone(),
-                changes_above_cursor: true,
-                multiple_suggestions: false,
-                branch: None,
-                file_chunks,
-                retrieval_chunks,
-                recent_user_actions,
-                use_bytes: true,
-                privacy_mode_enabled,
-            };
-
-            let mut buf: Vec<u8> = Vec::new();
-            let writer = brotli::CompressorWriter::new(&mut buf, 4096, 1, 22);
-            serde_json::to_writer(writer, &request_body)?;
-            let body: AsyncBody = buf.into();
-
-            let ep_inputs = zeta_prompt::ZetaPromptInput {
-                events: inputs.events,
-                related_files: Some(inputs.related_files.clone()),
-                active_buffer_diagnostics: vec![],
-                cursor_path: full_path.clone(),
-                cursor_excerpt: request_body.file_contents.clone().into(),
-                cursor_offset_in_excerpt: request_body.cursor_position,
-                excerpt_start_row: Some(0),
-                excerpt_ranges: zeta_prompt::ExcerptRanges {
-                    editable_150: 0..inputs.snapshot.len(),
-                    editable_180: 0..inputs.snapshot.len(),
-                    editable_350: 0..inputs.snapshot.len(),
-                    editable_150_context_350: 0..inputs.snapshot.len(),
-                    editable_180_context_350: 0..inputs.snapshot.len(),
-                    editable_350_context_150: 0..inputs.snapshot.len(),
-                    ..Default::default()
-                },
-                syntax_ranges: None,
-                experiment: None,
-                in_open_source_repo: false,
-                can_collect_data: false,
-                repo_url: None,
-            };
-
-            send_started_event(
-                &debug_tx,
-                &buffer,
-                inputs.position,
-                serde_json::to_string(&request_body).unwrap_or_default(),
-            );
-
-            let request = http_client::Request::builder()
-                .uri(SWEEP_API_URL)
-                .header("Content-Type", "application/json")
-                .header("Authorization", format!("Bearer {}", api_token))
-                .header("Connection", "keep-alive")
-                .header("Content-Encoding", "br")
-                .method(Method::POST)
-                .body(body)?;
-
-            let mut response = http_client.send(request).await?;
-
-            let mut body = String::new();
-            response.body_mut().read_to_string(&mut body).await?;
-
-            if !response.status().is_success() {
-                let message = format!(
-                    "Request failed with status: {:?}\nBody: {}",
-                    response.status(),
-                    body,
-                );
-                send_finished_event(&debug_tx, &buffer, inputs.position, message.clone());
-                bail!(message);
-            };
-
-            let response: AutocompleteResponse = serde_json::from_str(&body)?;
-
-            send_finished_event(&debug_tx, &buffer, inputs.position, body);
-
-            let old_text = inputs
-                .snapshot
-                .text_for_range(response.start_index..response.end_index)
-                .collect::<String>();
-            let edits = language::text_diff(&old_text, &response.completion)
-                .into_iter()
-                .map(|(range, text)| {
-                    (
-                        inputs
-                            .snapshot
-                            .anchor_after(response.start_index + range.start)
-                            ..inputs
-                                .snapshot
-                                .anchor_before(response.start_index + range.end),
-                        text,
-                    )
-                })
-                .collect::<Vec<_>>();
-
-            anyhow::Ok((response.autocomplete_id, edits, inputs.snapshot, ep_inputs))
-        });
-
-        let buffer = inputs.buffer.clone();
-
-        cx.spawn(async move |cx| {
-            let (id, edits, old_snapshot, inputs) = result.await?;
-            anyhow::Ok(Some(
-                EditPredictionResult::new(
-                    EditPredictionId(id.into()),
-                    &buffer,
-                    &old_snapshot,
-                    edits.into(),
-                    None,
-                    inputs,
-                    None,
-                    cx.background_executor().now() - request_start,
-                    cx,
-                )
-                .await,
-            ))
-        })
-    }
-}
-
-fn send_started_event(
-    debug_tx: &Option<mpsc::UnboundedSender<DebugEvent>>,
-    buffer: &Entity<Buffer>,
-    position: Anchor,
-    prompt: String,
-) {
-    if let Some(debug_tx) = debug_tx {
-        _ = debug_tx.unbounded_send(DebugEvent::EditPredictionStarted(
-            EditPredictionStartedDebugEvent {
-                buffer: buffer.downgrade(),
-                position,
-                prompt: Some(prompt),
-            },
-        ));
-    }
-}
-
-fn send_finished_event(
-    debug_tx: &Option<mpsc::UnboundedSender<DebugEvent>>,
-    buffer: &Entity<Buffer>,
-    position: Anchor,
-    model_output: String,
-) {
-    if let Some(debug_tx) = debug_tx {
-        _ = debug_tx.unbounded_send(DebugEvent::EditPredictionFinished(
-            EditPredictionFinishedDebugEvent {
-                buffer: buffer.downgrade(),
-                position,
-                model_output: Some(model_output),
-            },
-        ));
-    }
-}
-
-pub const SWEEP_CREDENTIALS_URL: SharedString =
-    SharedString::new_static("https://autocomplete.sweep.dev");
-pub const SWEEP_CREDENTIALS_USERNAME: &str = "sweep-api-token";
-pub static SWEEP_AI_TOKEN_ENV_VAR: std::sync::LazyLock<EnvVar> = env_var!("SWEEP_AI_TOKEN");
-
-struct GlobalSweepApiKey(Entity<ApiKeyState>);
-
-impl Global for GlobalSweepApiKey {}
-
-pub fn sweep_api_token(cx: &mut App) -> Entity<ApiKeyState> {
-    if let Some(global) = cx.try_global::<GlobalSweepApiKey>() {
-        return global.0.clone();
-    }
-    let entity =
-        cx.new(|_| ApiKeyState::new(SWEEP_CREDENTIALS_URL, SWEEP_AI_TOKEN_ENV_VAR.clone()));
-    cx.set_global(GlobalSweepApiKey(entity.clone()));
-    entity
-}
-
-pub fn load_sweep_api_token(cx: &mut App) -> Task<Result<(), language_model::AuthenticateError>> {
-    sweep_api_token(cx).update(cx, |key_state, cx| {
-        key_state.load_if_needed(SWEEP_CREDENTIALS_URL, |s| s, cx)
-    })
-}
-
-#[derive(Debug, Clone, Serialize)]
-struct AutocompleteRequest {
-    pub debug_info: Arc<str>,
-    pub repo_name: String,
-    pub branch: Option<String>,
-    pub file_path: Arc<Path>,
-    pub file_contents: String,
-    pub recent_changes: String,
-    pub cursor_position: usize,
-    pub original_file_contents: String,
-    pub file_chunks: Vec<FileChunk>,
-    pub retrieval_chunks: Vec<FileChunk>,
-    pub recent_user_actions: Vec<UserAction>,
-    pub multiple_suggestions: bool,
-    pub privacy_mode_enabled: bool,
-    pub changes_above_cursor: bool,
-    pub use_bytes: bool,
-}
-
-#[derive(Debug, Clone, Serialize)]
-struct FileChunk {
-    pub file_path: String,
-    pub start_line: usize,
-    pub end_line: usize,
-    pub content: String,
-    pub timestamp: Option<u64>,
-}
-
-#[derive(Debug, Clone, Serialize)]
-struct UserAction {
-    pub action_type: ActionType,
-    pub line_number: usize,
-    pub offset: usize,
-    pub file_path: String,
-    pub timestamp: u64,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
-#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
-enum ActionType {
-    CursorMovement,
-    InsertChar,
-    DeleteChar,
-    InsertSelection,
-    DeleteSelection,
-}
-
-fn to_sweep_user_action(record: &UserActionRecord, file_path: &str) -> UserAction {
-    UserAction {
-        action_type: match record.action_type {
-            UserActionType::InsertChar => ActionType::InsertChar,
-            UserActionType::InsertSelection => ActionType::InsertSelection,
-            UserActionType::DeleteChar => ActionType::DeleteChar,
-            UserActionType::DeleteSelection => ActionType::DeleteSelection,
-            UserActionType::CursorMovement => ActionType::CursorMovement,
-        },
-        line_number: record.line_number as usize,
-        offset: record.offset,
-        file_path: file_path.to_string(),
-        timestamp: record.timestamp_epoch_ms,
-    }
-}
-
-#[derive(Debug, Clone, Deserialize)]
-struct AutocompleteResponse {
-    pub autocomplete_id: String,
-    pub start_index: usize,
-    pub end_index: usize,
-    pub completion: String,
-    #[allow(dead_code)]
-    pub confidence: f64,
-    #[allow(dead_code)]
-    pub logprobs: Option<serde_json::Value>,
-    #[allow(dead_code)]
-    pub finish_reason: Option<String>,
-    #[allow(dead_code)]
-    pub elapsed_time_ms: u64,
-    #[allow(dead_code)]
-    #[serde(default, rename = "completions")]
-    pub additional_completions: Vec<AdditionalCompletion>,
-}
-
-#[allow(dead_code)]
-#[derive(Debug, Clone, Deserialize)]
-struct AdditionalCompletion {
-    pub start_index: usize,
-    pub end_index: usize,
-    pub completion: String,
-    pub confidence: f64,
-    pub autocomplete_id: String,
-    pub logprobs: Option<serde_json::Value>,
-    pub finish_reason: Option<String>,
-}
-
-fn write_event(event: &zeta_prompt::Event, f: &mut impl fmt::Write) -> fmt::Result {
-    match event {
-        zeta_prompt::Event::BufferChange {
-            old_path,
-            path,
-            diff,
-            ..
-        } => {
-            if old_path != path {
-                // TODO confirm how to do this for sweep
-                // writeln!(f, "User renamed {:?} to {:?}\n", old_path, new_path)?;
-            }
-
-            if !diff.is_empty() {
-                write!(f, "File: {}:\n{}\n", path.display(), diff)?
-            }
-
-            fmt::Result::Ok(())
-        }
-    }
-}
-
-fn debug_info(cx: &gpui::App) -> Arc<str> {
-    format!(
-        "Zed v{version} ({sha}) - OS: {os} - Zed v{version}",
-        version = release_channel::AppVersion::global(cx),
-        sha = release_channel::AppCommitSha::try_global(cx)
-            .map_or("unknown".to_string(), |sha| sha.full()),
-        os = client::telemetry::os_name(),
-    )
-    .into()
-}
-
-#[derive(Debug, Clone, Copy, Serialize)]
-#[serde(rename_all = "snake_case")]
-pub enum SweepEventType {
-    AutocompleteSuggestionShown,
-    AutocompleteSuggestionAccepted,
-}
-
-#[derive(Debug, Clone, Copy, Serialize)]
-#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
-pub enum SweepSuggestionType {
-    GhostText,
-    Popup,
-    JumpToEdit,
-}
-
-#[derive(Debug, Clone, Serialize)]
-struct AutocompleteMetricsRequest {
-    event_type: SweepEventType,
-    suggestion_type: SweepSuggestionType,
-    additions: u32,
-    deletions: u32,
-    autocomplete_id: String,
-    edit_tracking: String,
-    edit_tracking_line: Option<u32>,
-    lifespan: Option<u64>,
-    debug_info: Arc<str>,
-    device_id: String,
-    privacy_mode_enabled: bool,
-}
-
-fn send_autocomplete_metrics_request(
-    cx: &App,
-    client: Arc<Client>,
-    api_token: Arc<str>,
-    request_body: AutocompleteMetricsRequest,
-) {
-    let http_client = client.http_client();
-    cx.background_spawn(async move {
-        let body: AsyncBody = serde_json::to_string(&request_body)?.into();
-
-        let request = http_client::Request::builder()
-            .uri(SWEEP_METRICS_URL)
-            .header("Content-Type", "application/json")
-            .header("Authorization", format!("Bearer {}", api_token))
-            .method(Method::POST)
-            .body(body)?;
-
-        let mut response = http_client.send(request).await?;
-
-        if !response.status().is_success() {
-            let mut body = String::new();
-            response.body_mut().read_to_string(&mut body).await?;
-            anyhow::bail!(
-                "Failed to send autocomplete metrics for sweep_ai: {:?}\nBody: {}",
-                response.status(),
-                body,
-            );
-        }
-
-        Ok(())
-    })
-    .detach_and_log_err(cx);
-}
-
-pub(crate) fn edit_prediction_accepted(
-    store: &EditPredictionStore,
-    current_prediction: CurrentEditPrediction,
-    cx: &App,
-) {
-    let Some(api_token) = store
-        .sweep_ai
-        .api_token
-        .read(cx)
-        .key(&SWEEP_CREDENTIALS_URL)
-    else {
-        return;
-    };
-    let debug_info = store.sweep_ai.debug_info.clone();
-
-    let prediction = current_prediction.prediction;
-
-    let (additions, deletions) = compute_edit_metrics(&prediction.edits, &prediction.snapshot);
-    let autocomplete_id = prediction.id.to_string();
-
-    let device_id = store
-        .client
-        .user_id()
-        .as_ref()
-        .map(ToString::to_string)
-        .unwrap_or_default();
-
-    let suggestion_type = match current_prediction.shown_with {
-        Some(SuggestionDisplayType::DiffPopover) => SweepSuggestionType::Popup,
-        Some(SuggestionDisplayType::Jump) => return, // should'nt happen
-        Some(SuggestionDisplayType::GhostText) | None => SweepSuggestionType::GhostText,
-    };
-
-    let request_body = AutocompleteMetricsRequest {
-        event_type: SweepEventType::AutocompleteSuggestionAccepted,
-        suggestion_type,
-        additions,
-        deletions,
-        autocomplete_id,
-        edit_tracking: String::new(),
-        edit_tracking_line: None,
-        lifespan: None,
-        debug_info,
-        device_id,
-        privacy_mode_enabled: false,
-    };
-
-    send_autocomplete_metrics_request(cx, store.client.clone(), api_token, request_body);
-}
-
-pub fn edit_prediction_shown(
-    sweep_ai: &SweepAi,
-    client: Arc<Client>,
-    prediction: &EditPrediction,
-    display_type: SuggestionDisplayType,
-    cx: &App,
-) {
-    let Some(api_token) = sweep_ai.api_token.read(cx).key(&SWEEP_CREDENTIALS_URL) else {
-        return;
-    };
-    let debug_info = sweep_ai.debug_info.clone();
-
-    let (additions, deletions) = compute_edit_metrics(&prediction.edits, &prediction.snapshot);
-    let autocomplete_id = prediction.id.to_string();
-
-    let suggestion_type = match display_type {
-        SuggestionDisplayType::GhostText => SweepSuggestionType::GhostText,
-        SuggestionDisplayType::DiffPopover => SweepSuggestionType::Popup,
-        SuggestionDisplayType::Jump => SweepSuggestionType::JumpToEdit,
-    };
-
-    let request_body = AutocompleteMetricsRequest {
-        event_type: SweepEventType::AutocompleteSuggestionShown,
-        suggestion_type,
-        additions,
-        deletions,
-        autocomplete_id,
-        edit_tracking: String::new(),
-        edit_tracking_line: None,
-        lifespan: None,
-        debug_info,
-        device_id: String::new(),
-        privacy_mode_enabled: false,
-    };
-
-    send_autocomplete_metrics_request(cx, client, api_token, request_body);
-}
-
-fn compute_edit_metrics(
-    edits: &[(Range<Anchor>, Arc<str>)],
-    snapshot: &BufferSnapshot,
-) -> (u32, u32) {
-    let mut additions = 0u32;
-    let mut deletions = 0u32;
-
-    for (range, new_text) in edits {
-        let old_text = snapshot.text_for_range(range.clone());
-        deletions += old_text
-            .map(|chunk| chunk.lines().count())
-            .sum::<usize>()
-            .max(1) as u32;
-        additions += new_text.lines().count().max(1) as u32;
-    }
-
-    (additions, deletions)
-}

crates/edit_prediction/src/zed_edit_prediction_delegate.rs πŸ”—

@@ -10,7 +10,7 @@ use gpui::{App, Entity, prelude::*};
 use language::{Buffer, ToPoint as _};
 use project::Project;
 
-use crate::{BufferEditPrediction, EditPredictionModel, EditPredictionStore};
+use crate::{BufferEditPrediction, EditPredictionStore};
 
 pub struct ZedEditPredictionDelegate {
     store: Entity<EditPredictionStore>,
@@ -103,14 +103,9 @@ impl EditPredictionDelegate for ZedEditPredictionDelegate {
         &self,
         _buffer: &Entity<language::Buffer>,
         _cursor_position: language::Anchor,
-        cx: &App,
+        _cx: &App,
     ) -> bool {
-        let store = self.store.read(cx);
-        if store.edit_prediction_model == EditPredictionModel::Sweep {
-            store.has_sweep_api_token(cx)
-        } else {
-            true
-        }
+        true
     }
 
     fn is_refreshing(&self, cx: &App) -> bool {

crates/edit_prediction_cli/src/example.rs πŸ”—

@@ -1,4 +1,5 @@
 use crate::PredictionProvider;
+use crate::metrics::ClassificationMetrics;
 use crate::paths::WORKTREES_DIR;
 use crate::qa::QaResult;
 use anyhow::{Context as _, Result};
@@ -150,6 +151,18 @@ where
 #[derive(Clone, Debug, Serialize, Deserialize)]
 pub struct ExampleScore {
     pub delta_chr_f: f32,
+    #[serde(default)]
+    pub delta_chr_f_true_positives: usize,
+    #[serde(default)]
+    pub delta_chr_f_false_positives: usize,
+    #[serde(default)]
+    pub delta_chr_f_false_negatives: usize,
+    #[serde(default)]
+    pub delta_chr_f_precision: f64,
+    #[serde(default)]
+    pub delta_chr_f_recall: f64,
+    #[serde(default)]
+    pub delta_chr_f_beta: f64,
     pub braces_disbalance: usize,
     #[serde(default)]
     pub exact_lines_tp: usize,
@@ -176,6 +189,24 @@ pub struct ExampleScore {
     pub avg_logprob: Option<f64>,
 }
 
+impl ExampleScore {
+    pub fn delta_chr_f_counts(&self) -> ClassificationMetrics {
+        ClassificationMetrics {
+            true_positives: self.delta_chr_f_true_positives,
+            false_positives: self.delta_chr_f_false_positives,
+            false_negatives: self.delta_chr_f_false_negatives,
+        }
+    }
+
+    pub fn exact_lines_counts(&self) -> ClassificationMetrics {
+        ClassificationMetrics {
+            true_positives: self.exact_lines_tp,
+            false_positives: self.exact_lines_fp,
+            false_negatives: self.exact_lines_fn,
+        }
+    }
+}
+
 impl Example {
     pub fn repo_name(&self) -> Result<RepoName<'_>> {
         // git@github.com:owner/repo.git

crates/edit_prediction_cli/src/filter_languages.rs πŸ”—

@@ -13,7 +13,7 @@
 //!
 //! Language is detected based on file extension of the `cursor_path` field.
 //! The extension-to-language mapping is built from the embedded language
-//! config files in the `languages` crate.
+//! config files in the `grammars` crate.
 
 use anyhow::{Context as _, Result, bail};
 use clap::Args;
@@ -29,7 +29,7 @@ mod language_configs_embedded {
     use rust_embed::RustEmbed;
 
     #[derive(RustEmbed)]
-    #[folder = "../languages/src/"]
+    #[folder = "../grammars/src/"]
     #[include = "*/config.toml"]
     pub struct LanguageConfigs;
 }
@@ -123,7 +123,7 @@ fn build_extension_to_language_map() -> HashMap<String, String> {
 
 #[cfg(feature = "dynamic_prompts")]
 fn build_extension_to_language_map() -> HashMap<String, String> {
-    const LANGUAGES_SRC_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../languages/src");
+    const LANGUAGES_SRC_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../grammars/src");
 
     let mut map = HashMap::default();
 

crates/edit_prediction_cli/src/main.rs πŸ”—

@@ -358,7 +358,6 @@ impl TeacherBackend {
 
 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
 enum PredictionProvider {
-    Sweep,
     Mercury,
     Zeta1,
     Zeta2(ZetaFormat),
@@ -379,7 +378,6 @@ impl Default for PredictionProvider {
 impl std::fmt::Display for PredictionProvider {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         match self {
-            PredictionProvider::Sweep => write!(f, "sweep"),
             PredictionProvider::Mercury => write!(f, "mercury"),
             PredictionProvider::Zeta1 => write!(f, "zeta1"),
             PredictionProvider::Zeta2(format) => write!(f, "zeta2:{format}"),
@@ -407,7 +405,6 @@ impl std::str::FromStr for PredictionProvider {
 
         let provider_lower = provider.to_lowercase();
         match provider_lower.as_str() {
-            "sweep" => Ok(PredictionProvider::Sweep),
             "mercury" => Ok(PredictionProvider::Mercury),
             "zeta1" => Ok(PredictionProvider::Zeta1),
             "zeta2" => {
@@ -452,7 +449,7 @@ impl std::str::FromStr for PredictionProvider {
             }
             _ => {
                 anyhow::bail!(
-                    "unknown provider `{provider}`. Valid options: sweep, mercury, zeta1, zeta2, zeta2:<version>, teacher, teacher:<backend>, teacher-multi-region, teacher-multi-region:<backend>, teacher-non-batching, teacher-multi-region-non-batching, repair\n\
+                    "unknown provider `{provider}`. Valid options: mercury, zeta1, zeta2, zeta2:<version>, teacher, teacher:<backend>, teacher-multi-region, teacher-multi-region:<backend>, teacher-non-batching, teacher-multi-region-non-batching, repair\n\
                  For zeta2, you can optionally specify a version like `zeta2:ordered` or `zeta2:V0113_Ordered`.\n\
                  For teacher providers, you can specify a backend like `teacher:sonnet46`, `teacher-multi-region:sonnet46`, `teacher-multi-region-non-batching:sonnet46`, or `teacher:gpt52`.\n\
                  Available zeta versions:\n{}",

crates/edit_prediction_cli/src/metrics.rs πŸ”—

@@ -48,6 +48,12 @@ impl ClassificationMetrics {
         }
     }
 
+    pub fn accumulate(&mut self, other: &ClassificationMetrics) {
+        self.true_positives += other.true_positives;
+        self.false_positives += other.false_positives;
+        self.false_negatives += other.false_negatives;
+    }
+
     pub fn precision(&self) -> f64 {
         if self.true_positives + self.false_positives == 0 {
             0.0
@@ -89,10 +95,23 @@ enum ChrfWhitespace {
 }
 
 const CHR_F_CHAR_ORDER: usize = 6;
-const CHR_F_BETA: f64 = 2.0;
+const CHR_F_BETA: f64 = 0.5;
 const CHR_F_WHITESPACE: ChrfWhitespace = ChrfWhitespace::Collapse;
 
-/// Computes a delta-chrF score that compares two sets of edits.
+pub fn delta_chr_f_beta() -> f64 {
+    CHR_F_BETA
+}
+
+#[derive(Default, Debug, Clone)]
+pub struct DeltaChrFMetrics {
+    pub score: f64,
+    pub beta: f64,
+    pub counts: ClassificationMetrics,
+    pub precision: f64,
+    pub recall: f64,
+}
+
+/// Computes delta-chrF metrics that compare two sets of edits.
 ///
 /// This metric works by:
 /// 1. Computing n-gram count differences (deltas) between original→expected and original→actual
@@ -100,13 +119,17 @@ const CHR_F_WHITESPACE: ChrfWhitespace = ChrfWhitespace::Collapse;
 ///
 /// Returns a score from 0.0 to 100.0, where 100.0 means the actual edits perfectly match
 /// the expected edits.
-pub fn delta_chr_f(original: &str, expected: &str, actual: &str) -> f64 {
-    // Edge case: if all texts are identical, the edits match perfectly
+pub fn delta_chr_f(original: &str, expected: &str, actual: &str) -> DeltaChrFMetrics {
     if original == expected && expected == actual {
-        return 100.0;
+        return DeltaChrFMetrics {
+            score: 100.0,
+            beta: CHR_F_BETA,
+            precision: 1.0,
+            recall: 1.0,
+            ..DeltaChrFMetrics::default()
+        };
     }
 
-    // Pre-filter whitespace once for all texts
     let orig_chars: Vec<char> = filter_whitespace_chars(original);
     let exp_chars: Vec<char> = filter_whitespace_chars(expected);
     let act_chars: Vec<char> = filter_whitespace_chars(actual);
@@ -118,9 +141,9 @@ pub fn delta_chr_f(original: &str, expected: &str, actual: &str) -> f64 {
 
     let mut total_precision = 0.0;
     let mut total_recall = 0.0;
+    let mut total_counts = ClassificationMetrics::default();
 
     for order in 1..=CHR_F_CHAR_ORDER {
-        // Compute n-grams only on the affected regions
         let orig_ngrams_for_exp = count_ngrams_from_chars(&orig_for_exp, order);
         let exp_ngrams = count_ngrams_from_chars(&exp_region, order);
         let expected_delta = compute_ngram_delta(&exp_ngrams, &orig_ngrams_for_exp);
@@ -138,28 +161,43 @@ pub fn delta_chr_f(original: &str, expected: &str, actual: &str) -> f64 {
         let expected_counts = ngram_delta_to_counts(&expected_delta);
         let actual_counts = ngram_delta_to_counts(&actual_delta);
 
-        let score = ClassificationMetrics::from_counts(&expected_counts, &actual_counts);
-        total_precision += score.precision();
-        total_recall += score.recall();
+        let counts = ClassificationMetrics::from_counts(&expected_counts, &actual_counts);
+        total_precision += counts.precision();
+        total_recall += counts.recall();
+        total_counts.accumulate(&counts);
     }
 
-    let prec = total_precision / CHR_F_CHAR_ORDER as f64;
-    let recall = total_recall / CHR_F_CHAR_ORDER as f64;
-    let f_score = if prec + recall == 0.0 {
+    let average_precision = total_precision / CHR_F_CHAR_ORDER as f64;
+    let average_recall = total_recall / CHR_F_CHAR_ORDER as f64;
+    let score = if average_precision + average_recall == 0.0 {
         0.0
     } else {
-        (1.0 + CHR_F_BETA * CHR_F_BETA) * prec * recall / (CHR_F_BETA * CHR_F_BETA * prec + recall)
+        (1.0 + CHR_F_BETA * CHR_F_BETA) * average_precision * average_recall
+            / (CHR_F_BETA * CHR_F_BETA * average_precision + average_recall)
+            * 100.0
     };
 
-    f_score * 100.0
+    DeltaChrFMetrics {
+        score,
+        beta: CHR_F_BETA,
+        counts: total_counts,
+        precision: average_precision,
+        recall: average_recall,
+    }
 }
 
-/// Reference implementation of delta_chr_f (original, non-optimized version).
+/// Reference implementation of delta-chrF metrics (original, non-optimized version).
 /// Used for testing that the optimized version produces identical results.
 #[cfg(test)]
-fn delta_chr_f_reference(original: &str, expected: &str, actual: &str) -> f64 {
+fn delta_chr_f_reference(original: &str, expected: &str, actual: &str) -> DeltaChrFMetrics {
     if original == expected && expected == actual {
-        return 100.0;
+        return DeltaChrFMetrics {
+            score: 100.0,
+            beta: CHR_F_BETA,
+            precision: 1.0,
+            recall: 1.0,
+            ..DeltaChrFMetrics::default()
+        };
     }
 
     let original_ngrams = chr_f_ngram_counts(original);
@@ -168,6 +206,7 @@ fn delta_chr_f_reference(original: &str, expected: &str, actual: &str) -> f64 {
 
     let mut total_precision = 0.0;
     let mut total_recall = 0.0;
+    let mut total_counts = ClassificationMetrics::default();
 
     for order in 0..CHR_F_CHAR_ORDER {
         let expected_delta = compute_ngram_delta(&expected_ngrams[order], &original_ngrams[order]);
@@ -182,20 +221,29 @@ fn delta_chr_f_reference(original: &str, expected: &str, actual: &str) -> f64 {
         let expected_counts = ngram_delta_to_counts(&expected_delta);
         let actual_counts = ngram_delta_to_counts(&actual_delta);
 
-        let score = ClassificationMetrics::from_counts(&expected_counts, &actual_counts);
-        total_precision += score.precision();
-        total_recall += score.recall();
+        let counts = ClassificationMetrics::from_counts(&expected_counts, &actual_counts);
+        total_precision += counts.precision();
+        total_recall += counts.recall();
+        total_counts.accumulate(&counts);
     }
 
-    let prec = total_precision / CHR_F_CHAR_ORDER as f64;
-    let recall = total_recall / CHR_F_CHAR_ORDER as f64;
-    let f_score = if prec + recall == 0.0 {
+    let average_precision = total_precision / CHR_F_CHAR_ORDER as f64;
+    let average_recall = total_recall / CHR_F_CHAR_ORDER as f64;
+    let score = if average_precision + average_recall == 0.0 {
         0.0
     } else {
-        (1.0 + CHR_F_BETA * CHR_F_BETA) * prec * recall / (CHR_F_BETA * CHR_F_BETA * prec + recall)
+        (1.0 + CHR_F_BETA * CHR_F_BETA) * average_precision * average_recall
+            / (CHR_F_BETA * CHR_F_BETA * average_precision + average_recall)
+            * 100.0
     };
 
-    f_score * 100.0
+    DeltaChrFMetrics {
+        score,
+        beta: CHR_F_BETA,
+        counts: total_counts,
+        precision: average_precision,
+        recall: average_recall,
+    }
 }
 
 /// Filter whitespace from a string and return as Vec<char>
@@ -664,7 +712,7 @@ mod test_optimization {
         ];
 
         for (original, expected, actual) in test_cases {
-            let score = delta_chr_f(original, expected, actual);
+            let score = delta_chr_f(original, expected, actual).score;
             // Just verify it produces a reasonable score (0-100)
             assert!(
                 score >= 0.0 && score <= 100.0,
@@ -733,20 +781,51 @@ mod test_optimization {
         ];
 
         for (original, expected, actual) in test_cases {
-            let optimized_score = delta_chr_f(original, expected, actual);
-            let reference_score = delta_chr_f_reference(original, expected, actual);
+            let optimized_metrics = delta_chr_f(original, expected, actual);
+            let reference_metrics = delta_chr_f_reference(original, expected, actual);
 
             assert!(
-                (optimized_score - reference_score).abs() < 1e-10,
-                "Mismatch for ({:?}, {:?}, {:?}):\n  optimized: {}\n  reference: {}",
+                (optimized_metrics.score - reference_metrics.score).abs() < 1e-10,
+                "Score mismatch for ({:?}, {:?}, {:?}):\n  optimized: {}\n  reference: {}",
                 original,
                 expected,
                 actual,
-                optimized_score,
-                reference_score
+                optimized_metrics.score,
+                reference_metrics.score
+            );
+            assert_eq!(
+                optimized_metrics.counts.true_positives,
+                reference_metrics.counts.true_positives
+            );
+            assert_eq!(
+                optimized_metrics.counts.false_positives,
+                reference_metrics.counts.false_positives
             );
+            assert_eq!(
+                optimized_metrics.counts.false_negatives,
+                reference_metrics.counts.false_negatives
+            );
+            assert!((optimized_metrics.precision - reference_metrics.precision).abs() < 1e-10);
+            assert!((optimized_metrics.recall - reference_metrics.recall).abs() < 1e-10);
         }
     }
+
+    #[test]
+    fn test_delta_chr_f_metrics_include_counts_and_rates() {
+        let original = "one two three";
+        let expected = "one three";
+        let actual = "one two four";
+
+        let metrics = delta_chr_f(original, expected, actual);
+
+        assert!(metrics.score > 20.0 && metrics.score < 40.0);
+        assert!(metrics.counts.true_positives > 0);
+        assert!(metrics.counts.false_positives > 0);
+        assert!(metrics.counts.false_negatives > 0);
+        assert!(metrics.precision > 0.0 && metrics.precision < 1.0);
+        assert!(metrics.recall > 0.0 && metrics.recall < 1.0);
+        assert_eq!(metrics.beta, CHR_F_BETA);
+    }
 }
 
 #[cfg(test)]
@@ -770,7 +849,7 @@ mod test {
         let original = "fn main() {    println!(\"Hello\");}";
         let expected = "fn main() {    println!(\"Hello, World!\");}";
 
-        let score = delta_chr_f(original, expected, expected);
+        let score = delta_chr_f(original, expected, expected).score;
         assert!((score - 100.0).abs() < 1e-2);
     }
 
@@ -782,7 +861,7 @@ mod test {
         let actual = "one two four"; // deleted "three", added "four"
 
         // Then the score should be low
-        let score = delta_chr_f(original, expected, actual);
+        let score = delta_chr_f(original, expected, actual).score;
         assert!(score > 20.0 && score < 40.0);
     }
 
@@ -794,7 +873,7 @@ mod test {
 
         // We got the edit location right, but the replacement text is wrong.
         // Deleted ngrams will match, bringing the score somewhere in the middle.
-        let score = delta_chr_f(original, expected, actual);
+        let score = delta_chr_f(original, expected, actual).score;
         assert!(score > 40.0 && score < 60.0);
     }
 
@@ -806,7 +885,7 @@ mod test {
         let actual = "prefix old suffix"; // no change
 
         // Then the score should be low (all expected changes are false negatives)
-        let score = delta_chr_f(original, expected, actual);
+        let score = delta_chr_f(original, expected, actual).score;
         assert!(score < 20.0);
     }
 
@@ -818,14 +897,14 @@ mod test {
         let actual = "helloextraworld"; // added "extra"
 
         // Then the score should be low (all actual changes are false positives)
-        let score = delta_chr_f(original, expected, actual);
+        let score = delta_chr_f(original, expected, actual).score;
         assert!(score < 20.0);
     }
 
     #[test]
     fn test_delta_chr_f_no_changes() {
         let text = "unchanged text";
-        let score = delta_chr_f(text, text, text);
+        let score = delta_chr_f(text, text, text).score;
         assert!((score - 100.0).abs() < 1e-2);
     }
 

crates/edit_prediction_cli/src/predict.rs πŸ”—

@@ -137,7 +137,6 @@ pub async fn run_prediction(
         let model = match provider {
             PredictionProvider::Zeta1 => edit_prediction::EditPredictionModel::Zeta,
             PredictionProvider::Zeta2(_) => edit_prediction::EditPredictionModel::Zeta,
-            PredictionProvider::Sweep => edit_prediction::EditPredictionModel::Sweep,
             PredictionProvider::Mercury => edit_prediction::EditPredictionModel::Mercury,
             PredictionProvider::Teacher(..)
             | PredictionProvider::TeacherMultiRegion(..)

crates/edit_prediction_cli/src/score.rs πŸ”—

@@ -67,6 +67,12 @@ pub async fn run_scoring(
 
     let zero_scores = ExampleScore {
         delta_chr_f: 0.0,
+        delta_chr_f_true_positives: 0,
+        delta_chr_f_false_positives: 0,
+        delta_chr_f_false_negatives: 0,
+        delta_chr_f_precision: 0.0,
+        delta_chr_f_recall: 0.0,
+        delta_chr_f_beta: metrics::delta_chr_f_beta(),
         braces_disbalance: 0,
         exact_lines_tp: 0,
         exact_lines_fp: 0,
@@ -111,14 +117,14 @@ pub async fn run_scoring(
             }
         };
 
-        let mut best_delta_chr_f = 0.0f32;
+        let mut best_delta_chr_f_metrics = metrics::DeltaChrFMetrics::default();
         let mut best_expected_cursor: Option<usize> = None;
         let mut best_patch_idx: Option<usize> = None;
 
         for (idx, expected) in expected_texts.iter().enumerate() {
-            let delta_chr_f = metrics::delta_chr_f(original_text, expected, &actual_text) as f32;
-            if delta_chr_f > best_delta_chr_f {
-                best_delta_chr_f = delta_chr_f;
+            let delta_chr_f_metrics = metrics::delta_chr_f(original_text, expected, &actual_text);
+            if delta_chr_f_metrics.score > best_delta_chr_f_metrics.score {
+                best_delta_chr_f_metrics = delta_chr_f_metrics;
                 best_patch_idx = Some(idx);
             }
         }
@@ -179,7 +185,13 @@ pub async fn run_scoring(
         );
 
         scores.push(ExampleScore {
-            delta_chr_f: best_delta_chr_f,
+            delta_chr_f: best_delta_chr_f_metrics.score as f32,
+            delta_chr_f_true_positives: best_delta_chr_f_metrics.counts.true_positives,
+            delta_chr_f_false_positives: best_delta_chr_f_metrics.counts.false_positives,
+            delta_chr_f_false_negatives: best_delta_chr_f_metrics.counts.false_negatives,
+            delta_chr_f_precision: best_delta_chr_f_metrics.precision,
+            delta_chr_f_recall: best_delta_chr_f_metrics.recall,
+            delta_chr_f_beta: best_delta_chr_f_metrics.beta,
             braces_disbalance,
             exact_lines_tp: best_exact_lines.true_positives,
             exact_lines_fp: best_exact_lines.false_positives,
@@ -238,6 +250,10 @@ pub fn print_report(examples: &[Example], verbose: bool) {
     let mut all_delta_chr_f_scores = Vec::new();
     let mut all_reversal_ratios = Vec::new();
     let mut braces_disbalance_sum: usize = 0;
+    let mut total_delta_chr_f = ClassificationMetrics::default();
+    let mut total_delta_chr_f_precision = 0.0;
+    let mut total_delta_chr_f_recall = 0.0;
+    let mut delta_chr_f_beta = 0.0;
     let mut total_exact_lines = ClassificationMetrics::default();
     let mut total_scores: usize = 0;
     let mut qa_reverts_count: usize = 0;
@@ -260,11 +276,7 @@ pub fn print_report(examples: &[Example], verbose: bool) {
 
     for example in examples {
         for (score_idx, score) in example.score.iter().enumerate() {
-            let exact_lines = ClassificationMetrics {
-                true_positives: score.exact_lines_tp,
-                false_positives: score.exact_lines_fp,
-                false_negatives: score.exact_lines_fn,
-            };
+            let exact_lines = score.exact_lines_counts();
 
             // Get QA results for this prediction if available
             let qa_result = example.qa.get(score_idx).and_then(|q| q.as_ref());
@@ -314,9 +326,11 @@ pub fn print_report(examples: &[Example], verbose: bool) {
             all_reversal_ratios.push(score.reversal_ratio);
             total_scores += 1;
             braces_disbalance_sum += score.braces_disbalance;
-            total_exact_lines.true_positives += score.exact_lines_tp;
-            total_exact_lines.false_positives += score.exact_lines_fp;
-            total_exact_lines.false_negatives += score.exact_lines_fn;
+            total_delta_chr_f.accumulate(&score.delta_chr_f_counts());
+            total_delta_chr_f_precision += score.delta_chr_f_precision;
+            total_delta_chr_f_recall += score.delta_chr_f_recall;
+            delta_chr_f_beta = score.delta_chr_f_beta;
+            total_exact_lines.accumulate(&score.exact_lines_counts());
 
             // Accumulate QA metrics
             if let Some(qa) = qa_result {
@@ -448,6 +462,15 @@ pub fn print_report(examples: &[Example], verbose: bool) {
             wrong_er_str
         );
         println!("{}", separator);
+        println!(
+            "Delta chrF (Ξ²={:.1}): TP={}, FP={}, FN={}, P={:.1}%, R={:.1}%",
+            delta_chr_f_beta,
+            total_delta_chr_f.true_positives,
+            total_delta_chr_f.false_positives,
+            total_delta_chr_f.false_negatives,
+            total_delta_chr_f_precision / total_scores as f64 * 100.0,
+            total_delta_chr_f_recall / total_scores as f64 * 100.0
+        );
 
         // Print additional cursor metrics if available
         if let Some(avg_dist) = avg_cursor_distance {
@@ -540,6 +563,12 @@ fn truncate_name(name: &str, max_len: usize) -> String {
 pub struct SummaryJson {
     pub total_examples: usize,
     pub avg_delta_chr_f: f32,
+    pub delta_chr_f_beta: f64,
+    pub delta_chr_f_true_positives: usize,
+    pub delta_chr_f_false_positives: usize,
+    pub delta_chr_f_false_negatives: usize,
+    pub delta_chr_f_precision: f64,
+    pub delta_chr_f_recall: f64,
     pub avg_braces_disbalance: f32,
     pub exact_lines_true_positives: usize,
     pub exact_lines_false_positives: usize,
@@ -569,6 +598,10 @@ pub fn compute_summary(examples: &[Example]) -> SummaryJson {
     let mut all_delta_chr_f_scores = Vec::new();
     let mut all_reversal_ratios = Vec::new();
     let mut braces_disbalance_sum: usize = 0;
+    let mut total_delta_chr_f = ClassificationMetrics::default();
+    let mut total_delta_chr_f_precision = 0.0;
+    let mut total_delta_chr_f_recall = 0.0;
+    let mut delta_chr_f_beta = 0.0;
     let mut total_exact_lines = ClassificationMetrics::default();
     let mut total_scores: usize = 0;
     let mut qa_reverts_count: usize = 0;
@@ -589,9 +622,11 @@ pub fn compute_summary(examples: &[Example]) -> SummaryJson {
             all_reversal_ratios.push(score.reversal_ratio);
             total_scores += 1;
             braces_disbalance_sum += score.braces_disbalance;
-            total_exact_lines.true_positives += score.exact_lines_tp;
-            total_exact_lines.false_positives += score.exact_lines_fp;
-            total_exact_lines.false_negatives += score.exact_lines_fn;
+            total_delta_chr_f.accumulate(&score.delta_chr_f_counts());
+            total_delta_chr_f_precision += score.delta_chr_f_precision;
+            total_delta_chr_f_recall += score.delta_chr_f_recall;
+            delta_chr_f_beta = score.delta_chr_f_beta;
+            total_exact_lines.accumulate(&score.exact_lines_counts());
 
             // Accumulate QA metrics
             if let Some(Some(qa)) = example.qa.get(score_idx) {
@@ -697,6 +732,20 @@ pub fn compute_summary(examples: &[Example]) -> SummaryJson {
     SummaryJson {
         total_examples: total_scores,
         avg_delta_chr_f,
+        delta_chr_f_beta,
+        delta_chr_f_true_positives: total_delta_chr_f.true_positives,
+        delta_chr_f_false_positives: total_delta_chr_f.false_positives,
+        delta_chr_f_false_negatives: total_delta_chr_f.false_negatives,
+        delta_chr_f_precision: if total_scores == 0 {
+            0.0
+        } else {
+            total_delta_chr_f_precision / total_scores as f64
+        },
+        delta_chr_f_recall: if total_scores == 0 {
+            0.0
+        } else {
+            total_delta_chr_f_recall / total_scores as f64
+        },
         avg_braces_disbalance,
         exact_lines_true_positives: total_exact_lines.true_positives,
         exact_lines_false_positives: total_exact_lines.false_positives,

crates/edit_prediction_context/src/edit_prediction_context_tests.rs πŸ”—

@@ -160,7 +160,7 @@ async fn test_edit_prediction_context(cx: &mut TestAppContext) {
 }
 
 #[gpui::test]
-fn test_assemble_excerpts(cx: &mut TestAppContext) {
+async fn test_assemble_excerpts(cx: &mut TestAppContext) {
     let table = [
         (
             indoc! {r#"
@@ -289,6 +289,9 @@ fn test_assemble_excerpts(cx: &mut TestAppContext) {
     for (input, expected_output) in table {
         let (input, ranges) = marked_text_ranges(&input, false);
         let buffer = cx.new(|cx| Buffer::local(input, cx).with_language(rust_lang(), cx));
+        buffer
+            .read_with(cx, |buffer, _| buffer.parsing_idle())
+            .await;
         buffer.read_with(cx, |buffer, _cx| {
             let ranges: Vec<(Range<Point>, usize)> = ranges
                 .into_iter()

crates/edit_prediction_ui/src/edit_prediction_button.rs πŸ”—

@@ -325,7 +325,6 @@ impl Render for EditPredictionButton {
             }
             provider @ (EditPredictionProvider::Experimental(_)
             | EditPredictionProvider::Zed
-            | EditPredictionProvider::Sweep
             | EditPredictionProvider::Mercury) => {
                 let enabled = self.editor_enabled.unwrap_or(true);
                 let file = self.file.clone();
@@ -349,16 +348,6 @@ impl Render for EditPredictionButton {
                 let mut missing_token = false;
 
                 match provider {
-                    EditPredictionProvider::Sweep => {
-                        missing_token = edit_prediction::EditPredictionStore::try_global(cx)
-                            .is_some_and(|ep_store| !ep_store.read(cx).has_sweep_api_token(cx));
-                        ep_icon = if enabled { icons.base } else { icons.disabled };
-                        tooltip_meta = if missing_token {
-                            "Missing API key for Sweep"
-                        } else {
-                            "Powered by Sweep"
-                        };
-                    }
                     EditPredictionProvider::Mercury => {
                         ep_icon = if enabled { icons.base } else { icons.disabled };
                         let mercury_has_error =
@@ -548,17 +537,12 @@ impl EditPredictionButton {
             .detach();
 
         edit_prediction::ollama::ensure_authenticated(cx);
-        let sweep_api_token_task = edit_prediction::sweep_ai::load_sweep_api_token(cx);
         let mercury_api_token_task = edit_prediction::mercury::load_mercury_api_token(cx);
         let open_ai_compatible_api_token_task =
             edit_prediction::open_ai_compatible::load_open_ai_compatible_api_token(cx);
 
         cx.spawn(async move |this, cx| {
-            _ = futures::join!(
-                sweep_api_token_task,
-                mercury_api_token_task,
-                open_ai_compatible_api_token_task
-            );
+            _ = futures::join!(mercury_api_token_task, open_ai_compatible_api_token_task);
             this.update(cx, |_, cx| {
                 cx.notify();
             })
@@ -1457,13 +1441,6 @@ pub fn get_available_providers(cx: &mut App) -> Vec<EditPredictionProvider> {
         providers.push(EditPredictionProvider::OpenAiCompatibleApi);
     }
 
-    if edit_prediction::sweep_ai::sweep_api_token(cx)
-        .read(cx)
-        .has_key()
-    {
-        providers.push(EditPredictionProvider::Sweep);
-    }
-
     if edit_prediction::mercury::mercury_api_token(cx)
         .read(cx)
         .has_key()

crates/editor/src/display_map.rs πŸ”—

@@ -101,6 +101,7 @@ use language::{
     Point, Subscription as BufferSubscription,
     language_settings::{AllLanguageSettings, LanguageSettings},
 };
+
 use multi_buffer::{
     Anchor, AnchorRangeExt, ExcerptId, MultiBuffer, MultiBufferOffset, MultiBufferOffsetUtf16,
     MultiBufferPoint, MultiBufferRow, MultiBufferSnapshot, RowInfo, ToOffset, ToPoint,
@@ -1905,7 +1906,7 @@ impl DisplaySnapshot {
         .flat_map(|chunk| {
             let syntax_highlight_style = chunk
                 .syntax_highlight_id
-                .and_then(|id| id.style(&editor_style.syntax));
+                .and_then(|id| editor_style.syntax.get(id).cloned());
 
             let chunk_highlight = chunk.highlight_style.map(|chunk_highlight| {
                 HighlightStyle {
@@ -1999,7 +2000,8 @@ impl DisplaySnapshot {
 
             let syntax_style = chunk
                 .syntax_highlight_id
-                .and_then(|id| id.style(syntax_theme));
+                .and_then(|id| syntax_theme.get(id).cloned());
+
             let overlay_style = chunk.highlight_style;
 
             let combined = match (syntax_style, overlay_style) {
@@ -4015,7 +4017,8 @@ pub mod tests {
         for chunk in snapshot.chunks(rows, true, HighlightStyles::default()) {
             let syntax_color = chunk
                 .syntax_highlight_id
-                .and_then(|id| id.style(theme)?.color);
+                .and_then(|id| theme.get(id)?.color);
+
             let highlight_color = chunk.highlight_style.and_then(|style| style.color);
             if let Some((last_chunk, last_syntax_color, last_highlight_color)) = chunks.last_mut()
                 && syntax_color == *last_syntax_color

crates/editor/src/editor.rs πŸ”—

@@ -601,7 +601,11 @@ pub fn make_inlay_hints_style(cx: &App) -> HighlightStyle {
         .inlay_hints
         .show_background;
 
-    let mut style = cx.theme().syntax().get("hint");
+    let mut style = cx
+        .theme()
+        .syntax()
+        .style_for_name("hint")
+        .unwrap_or_default();
 
     if style.color.is_none() {
         style.color = Some(cx.theme().status().hint);
@@ -19156,7 +19160,7 @@ impl Editor {
                                 move |cx: &mut BlockContext| {
                                     let mut text_style = cx.editor_style.text.clone();
                                     if let Some(highlight_style) = old_highlight_id
-                                        .and_then(|h| h.style(&cx.editor_style.syntax))
+                                        .and_then(|h| cx.editor_style.syntax.get(h).cloned())
                                     {
                                         text_style = text_style.highlight(highlight_style);
                                     }
@@ -25035,7 +25039,8 @@ impl Editor {
         for chunk in chunks {
             let highlight = chunk
                 .syntax_highlight_id
-                .and_then(|id| id.name(&style.syntax));
+                .and_then(|id| style.syntax.get_capture_name(id));
+
             let mut chunk_lines = chunk.text.split('\n').peekable();
             while let Some(text) = chunk_lines.next() {
                 let mut merged_with_last_token = false;
@@ -28843,49 +28848,58 @@ pub fn styled_runs_for_code_label<'a>(
         ..Default::default()
     };
 
+    if label.runs.is_empty() {
+        let desc_start = label.filter_range.end;
+        let fade_run =
+            (desc_start < label.text.len()).then(|| (desc_start..label.text.len(), fade_out));
+        return Either::Left(fade_run.into_iter());
+    }
+
     let mut prev_end = label.filter_range.end;
-    label
-        .runs
-        .iter()
-        .enumerate()
-        .flat_map(move |(ix, (range, highlight_id))| {
-            let style = if *highlight_id == language::HighlightId::TABSTOP_INSERT_ID {
-                HighlightStyle {
-                    color: Some(local_player.cursor),
-                    ..Default::default()
-                }
-            } else if *highlight_id == language::HighlightId::TABSTOP_REPLACE_ID {
-                HighlightStyle {
-                    background_color: Some(local_player.selection),
-                    ..Default::default()
-                }
-            } else if let Some(style) = highlight_id.style(syntax_theme) {
-                style
-            } else {
-                return Default::default();
-            };
-            let muted_style = style.highlight(fade_out);
+    Either::Right(
+        label
+            .runs
+            .iter()
+            .enumerate()
+            .flat_map(move |(ix, (range, highlight_id))| {
+                let style = if *highlight_id == language::HighlightId::TABSTOP_INSERT_ID {
+                    HighlightStyle {
+                        color: Some(local_player.cursor),
+                        ..Default::default()
+                    }
+                } else if *highlight_id == language::HighlightId::TABSTOP_REPLACE_ID {
+                    HighlightStyle {
+                        background_color: Some(local_player.selection),
+                        ..Default::default()
+                    }
+                } else if let Some(style) = syntax_theme.get(*highlight_id).cloned() {
+                    style
+                } else {
+                    return Default::default();
+                };
 
-            let mut runs = SmallVec::<[(Range<usize>, HighlightStyle); 3]>::new();
-            if range.start >= label.filter_range.end {
-                if range.start > prev_end {
-                    runs.push((prev_end..range.start, fade_out));
+                let mut runs = SmallVec::<[(Range<usize>, HighlightStyle); 3]>::new();
+                let muted_style = style.highlight(fade_out);
+                if range.start >= label.filter_range.end {
+                    if range.start > prev_end {
+                        runs.push((prev_end..range.start, fade_out));
+                    }
+                    runs.push((range.clone(), muted_style));
+                } else if range.end <= label.filter_range.end {
+                    runs.push((range.clone(), style));
+                } else {
+                    runs.push((range.start..label.filter_range.end, style));
+                    runs.push((label.filter_range.end..range.end, muted_style));
                 }
-                runs.push((range.clone(), muted_style));
-            } else if range.end <= label.filter_range.end {
-                runs.push((range.clone(), style));
-            } else {
-                runs.push((range.start..label.filter_range.end, style));
-                runs.push((label.filter_range.end..range.end, muted_style));
-            }
-            prev_end = cmp::max(prev_end, range.end);
+                prev_end = cmp::max(prev_end, range.end);
 
-            if ix + 1 == label.runs.len() && label.text.len() > prev_end {
-                runs.push((prev_end..label.text.len(), fade_out));
-            }
+                if ix + 1 == label.runs.len() && label.text.len() > prev_end {
+                    runs.push((prev_end..label.text.len(), fade_out));
+                }
 
-            runs
-        })
+                runs
+            }),
+    )
 }
 
 pub(crate) fn split_words(text: &str) -> impl std::iter::Iterator<Item = &str> + '_ {

crates/editor/src/semantic_tokens.rs πŸ”—

@@ -377,7 +377,10 @@ fn convert_token(
     for rule in matching {
         empty = false;
 
-        let style = rule.style.iter().find_map(|style| theme.get_opt(style));
+        let style = rule
+            .style
+            .iter()
+            .find_map(|style| theme.style_for_name(style));
 
         macro_rules! overwrite {
             (

crates/editor/src/signature_help.rs πŸ”—

@@ -6,6 +6,7 @@ use gpui::{
     TextStyle, Window, combine_highlights,
 };
 use language::BufferSnapshot;
+
 use markdown::{Markdown, MarkdownElement};
 use multi_buffer::{Anchor, MultiBufferOffset, ToOffset};
 use settings::Settings;
@@ -236,7 +237,7 @@ impl Editor {
                                     .highlight_text(&text, 0..signature.label.len())
                                     .into_iter()
                                     .flat_map(|(range, highlight_id)| {
-                                        Some((range, highlight_id.style(cx.theme().syntax())?))
+                                        Some((range, *cx.theme().syntax().get(highlight_id)?))
                                     });
                                 signature.highlights =
                                     combine_highlights(signature.highlights.clone(), highlights)

crates/extension_cli/src/main.rs πŸ”—

@@ -1,3 +1,4 @@
+use std::collections::BTreeSet;
 use std::collections::HashMap;
 use std::env;
 use std::fs;
@@ -7,6 +8,7 @@ use std::sync::Arc;
 use ::fs::{CopyOptions, Fs, RealFs, copy_recursive};
 use anyhow::{Context as _, Result, anyhow, bail};
 use clap::Parser;
+use cloud_api_types::ExtensionProvides;
 use extension::extension_builder::{CompileExtensionOptions, ExtensionBuilder};
 use extension::{ExtensionManifest, ExtensionSnippets};
 use language::LanguageConfig;
@@ -80,10 +82,7 @@ async fn main() -> Result<()> {
         .context("failed to compile extension")?;
 
     let extension_provides = manifest.provides();
-
-    if extension_provides.is_empty() {
-        bail!("extension does not provide any features");
-    }
+    validate_extension_features(&extension_provides)?;
 
     let grammars = test_grammars(&manifest, &extension_path, &mut wasm_store)?;
     test_languages(&manifest, &extension_path, &grammars)?;
@@ -203,7 +202,7 @@ async fn copy_extension_resources(
             },
         )
         .await
-        .with_context(|| "failed to copy icons")?;
+        .context("failed to copy icons")?;
     }
 
     for (_, agent_entry) in &manifest.agent_servers {
@@ -297,6 +296,22 @@ async fn copy_extension_resources(
     Ok(())
 }
 
+fn validate_extension_features(provides: &BTreeSet<ExtensionProvides>) -> Result<()> {
+    if provides.is_empty() {
+        bail!("extension does not provide any features");
+    }
+
+    if provides.contains(&ExtensionProvides::Themes) && provides.len() != 1 {
+        bail!("extension must not provide other features along with themes");
+    }
+
+    if provides.contains(&ExtensionProvides::IconThemes) && provides.len() != 1 {
+        bail!("extension must not provide other features along with icon themes");
+    }
+
+    Ok(())
+}
+
 fn test_grammars(
     manifest: &ExtensionManifest,
     extension_path: &Path,

crates/gpui/Cargo.toml πŸ”—

@@ -235,6 +235,10 @@ path = "examples/window_shadow.rs"
 name = "grid_layout"
 path = "examples/grid_layout.rs"
 
+[[example]]
+name = "list_example"
+path = "examples/list_example.rs"
+
 [[example]]
 name = "mouse_pressure"
 path = "examples/mouse_pressure.rs"

crates/gpui/examples/list_example.rs πŸ”—

@@ -0,0 +1,170 @@
+#![cfg_attr(target_family = "wasm", no_main)]
+
+use gpui::{
+    App, Bounds, Context, ListAlignment, ListState, Render, Window, WindowBounds, WindowOptions,
+    div, list, prelude::*, px, rgb, size,
+};
+use gpui_platform::application;
+
+const ITEM_COUNT: usize = 40;
+const SCROLLBAR_WIDTH: f32 = 12.;
+
+struct BottomListDemo {
+    list_state: ListState,
+}
+
+impl BottomListDemo {
+    fn new() -> Self {
+        Self {
+            list_state: ListState::new(ITEM_COUNT, ListAlignment::Bottom, px(500.)).measure_all(),
+        }
+    }
+}
+
+impl Render for BottomListDemo {
+    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+        let max_offset = self.list_state.max_offset_for_scrollbar().y;
+        let current_offset = -self.list_state.scroll_px_offset_for_scrollbar().y;
+
+        let viewport_height = self.list_state.viewport_bounds().size.height;
+
+        let raw_fraction = if max_offset > px(0.) {
+            current_offset / max_offset
+        } else {
+            0.
+        };
+
+        let total_height = viewport_height + max_offset;
+        let thumb_height = if total_height > px(0.) {
+            px(viewport_height.as_f32() * viewport_height.as_f32() / total_height.as_f32())
+                .max(px(30.))
+        } else {
+            px(30.)
+        };
+
+        let track_space = viewport_height - thumb_height;
+        let thumb_top = track_space * raw_fraction;
+
+        let bug_detected = raw_fraction > 1.0;
+
+        div()
+            .size_full()
+            .bg(rgb(0xFFFFFF))
+            .flex()
+            .flex_col()
+            .p_4()
+            .gap_2()
+            .child(
+                div()
+                    .text_sm()
+                    .flex()
+                    .flex_col()
+                    .gap_1()
+                    .child(format!(
+                        "offset: {:.0} / max: {:.0} | fraction: {:.3}",
+                        current_offset.as_f32(),
+                        max_offset.as_f32(),
+                        raw_fraction,
+                    ))
+                    .child(
+                        div()
+                            .text_color(if bug_detected {
+                                rgb(0xCC0000)
+                            } else {
+                                rgb(0x008800)
+                            })
+                            .child(if bug_detected {
+                                format!(
+                                    "BUG: fraction is {:.3} (> 1.0) β€” thumb is off-track!",
+                                    raw_fraction
+                                )
+                            } else {
+                                "OK: fraction <= 1.0 β€” thumb is within track.".to_string()
+                            }),
+                    ),
+            )
+            .child(
+                div()
+                    .flex_1()
+                    .flex()
+                    .flex_row()
+                    .overflow_hidden()
+                    .border_1()
+                    .border_color(rgb(0xCCCCCC))
+                    .rounded_sm()
+                    .child(
+                        list(self.list_state.clone(), |index, _window, _cx| {
+                            let height = px(30. + (index % 5) as f32 * 10.);
+                            div()
+                                .h(height)
+                                .w_full()
+                                .flex()
+                                .items_center()
+                                .px_3()
+                                .border_b_1()
+                                .border_color(rgb(0xEEEEEE))
+                                .bg(if index % 2 == 0 {
+                                    rgb(0xFAFAFA)
+                                } else {
+                                    rgb(0xFFFFFF)
+                                })
+                                .text_sm()
+                                .child(format!("Item {index}"))
+                                .into_any()
+                        })
+                        .flex_1(),
+                    )
+                    // Scrollbar track
+                    .child(
+                        div()
+                            .w(px(SCROLLBAR_WIDTH))
+                            .h_full()
+                            .flex_shrink_0()
+                            .bg(rgb(0xE0E0E0))
+                            .relative()
+                            .child(
+                                // Thumb β€” position is unclamped to expose the bug
+                                div()
+                                    .absolute()
+                                    .top(thumb_top)
+                                    .w_full()
+                                    .h(thumb_height)
+                                    .bg(if bug_detected {
+                                        rgb(0xCC0000)
+                                    } else {
+                                        rgb(0x888888)
+                                    })
+                                    .rounded_sm(),
+                            ),
+                    ),
+            )
+    }
+}
+
+fn run_example() {
+    application().run(|cx: &mut App| {
+        let bounds = Bounds::centered(None, size(px(400.), px(500.)), cx);
+        cx.open_window(
+            WindowOptions {
+                focus: true,
+                window_bounds: Some(WindowBounds::Windowed(bounds)),
+                ..Default::default()
+            },
+            |_, cx| cx.new(|_| BottomListDemo::new()),
+        )
+        .unwrap();
+        cx.activate(true);
+    });
+}
+
+#[cfg(not(target_family = "wasm"))]
+fn main() {
+    run_example();
+}
+
+#[cfg(target_family = "wasm")]
+#[wasm_bindgen::prelude::wasm_bindgen(start)]
+pub fn start() {
+    gpui_platform::web_init();
+    run_example();
+}

crates/gpui/src/elements/list.rs πŸ”—

@@ -72,6 +72,7 @@ struct StateInner {
     scrollbar_drag_start_height: Option<Pixels>,
     measuring_behavior: ListMeasuringBehavior,
     pending_scroll: Option<PendingScrollFraction>,
+    follow_tail: bool,
 }
 
 /// Keeps track of a fractional scroll position within an item for restoration
@@ -102,6 +103,9 @@ pub struct ListScrollEvent {
 
     /// Whether the list has been scrolled.
     pub is_scrolled: bool,
+
+    /// Whether the list is currently in follow-tail mode (auto-scrolling to end).
+    pub is_following_tail: bool,
 }
 
 /// The sizing behavior to apply during layout.
@@ -236,6 +240,7 @@ impl ListState {
             scrollbar_drag_start_height: None,
             measuring_behavior: ListMeasuringBehavior::default(),
             pending_scroll: None,
+            follow_tail: false,
         })));
         this.splice(0..0, item_count);
         this
@@ -394,6 +399,34 @@ impl ListState {
         });
     }
 
+    /// Scroll the list to the very end (past the last item).
+    ///
+    /// Unlike [`scroll_to_reveal_item`], this uses the total item count as the
+    /// anchor, so the list's layout pass will walk backwards from the end and
+    /// always show the bottom of the last item β€” even when that item is still
+    /// growing (e.g. during streaming).
+    pub fn scroll_to_end(&self) {
+        let state = &mut *self.0.borrow_mut();
+        let item_count = state.items.summary().count;
+        state.logical_scroll_top = Some(ListOffset {
+            item_ix: item_count,
+            offset_in_item: px(0.),
+        });
+    }
+
+    /// Set whether the list should automatically follow the tail (auto-scroll to the end).
+    pub fn set_follow_tail(&self, follow: bool) {
+        self.0.borrow_mut().follow_tail = follow;
+        if follow {
+            self.scroll_to_end();
+        }
+    }
+
+    /// Returns whether the list is currently in follow-tail mode (auto-scrolling to the end).
+    pub fn is_following_tail(&self) -> bool {
+        self.0.borrow().follow_tail
+    }
+
     /// Scroll the list to the given offset
     pub fn scroll_to(&self, mut scroll_top: ListOffset) {
         let state = &mut *self.0.borrow_mut();
@@ -493,18 +526,17 @@ impl ListState {
     /// This value remains constant while dragging to prevent the scrollbar from moving away unexpectedly.
     pub fn max_offset_for_scrollbar(&self) -> Point<Pixels> {
         let state = self.0.borrow();
-        let bounds = state.last_layout_bounds.unwrap_or_default();
-
-        let height = state
-            .scrollbar_drag_start_height
-            .unwrap_or_else(|| state.items.summary().height);
-
-        point(Pixels::ZERO, Pixels::ZERO.max(height - bounds.size.height))
+        point(Pixels::ZERO, state.max_scroll_offset())
     }
 
     /// Returns the current scroll offset adjusted for the scrollbar
     pub fn scroll_px_offset_for_scrollbar(&self) -> Point<Pixels> {
         let state = &self.0.borrow();
+
+        if state.logical_scroll_top.is_none() && state.alignment == ListAlignment::Bottom {
+            return Point::new(px(0.), -state.max_scroll_offset());
+        }
+
         let logical_scroll_top = state.logical_scroll_top();
 
         let mut cursor = state.items.cursor::<ListItemSummary>(());
@@ -526,6 +558,14 @@ impl ListState {
 }
 
 impl StateInner {
+    fn max_scroll_offset(&self) -> Pixels {
+        let bounds = self.last_layout_bounds.unwrap_or_default();
+        let height = self
+            .scrollbar_drag_start_height
+            .unwrap_or_else(|| self.items.summary().height);
+        (height - bounds.size.height).max(px(0.))
+    }
+
     fn visible_range(
         items: &SumTree<ListItem>,
         height: Pixels,
@@ -552,7 +592,6 @@ impl StateInner {
         if self.reset {
             return;
         }
-
         let padding = self.last_padding.unwrap_or_default();
         let scroll_max =
             (self.items.summary().height + padding.top + padding.bottom - height).max(px(0.));
@@ -574,6 +613,10 @@ impl StateInner {
             });
         }
 
+        if self.follow_tail && delta.y > px(0.) {
+            self.follow_tail = false;
+        }
+
         if let Some(handler) = self.scroll_handler.as_mut() {
             let visible_range = Self::visible_range(&self.items, height, scroll_top);
             handler(
@@ -581,6 +624,7 @@ impl StateInner {
                     visible_range,
                     count: self.items.summary().count,
                     is_scrolled: self.logical_scroll_top.is_some(),
+                    is_following_tail: self.follow_tail,
                 },
                 window,
                 cx,
@@ -670,6 +714,15 @@ impl StateInner {
         let mut rendered_height = padding.top;
         let mut max_item_width = px(0.);
         let mut scroll_top = self.logical_scroll_top();
+
+        if self.follow_tail {
+            scroll_top = ListOffset {
+                item_ix: self.items.summary().count,
+                offset_in_item: px(0.),
+            };
+            self.logical_scroll_top = Some(scroll_top);
+        }
+
         let mut rendered_focused_item = false;
 
         let available_item_space = size(
@@ -951,6 +1004,8 @@ impl StateInner {
             content_height - self.scrollbar_drag_start_height.unwrap_or(content_height);
         let new_scroll_top = (point.y - drag_offset).abs().max(px(0.)).min(scroll_max);
 
+        self.follow_tail = false;
+
         if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max {
             self.logical_scroll_top = None;
         } else {
@@ -1449,4 +1504,257 @@ mod test {
         assert_eq!(offset.item_ix, 2);
         assert_eq!(offset.offset_in_item, px(20.));
     }
+
+    #[gpui::test]
+    fn test_follow_tail_stays_at_bottom_as_items_grow(cx: &mut TestAppContext) {
+        let cx = cx.add_empty_window();
+
+        // 10 items, each 50px tall β†’ 500px total content, 200px viewport.
+        // With follow-tail on, the list should always show the bottom.
+        let item_height = Rc::new(Cell::new(50usize));
+        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
+
+        struct TestView {
+            state: ListState,
+            item_height: Rc<Cell<usize>>,
+        }
+        impl Render for TestView {
+            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+                let height = self.item_height.get();
+                list(self.state.clone(), move |_, _, _| {
+                    div().h(px(height as f32)).w_full().into_any()
+                })
+                .w_full()
+                .h_full()
+            }
+        }
+
+        let state_clone = state.clone();
+        let item_height_clone = item_height.clone();
+        let view = cx.update(|_, cx| {
+            cx.new(|_| TestView {
+                state: state_clone,
+                item_height: item_height_clone,
+            })
+        });
+
+        state.set_follow_tail(true);
+
+        // First paint β€” items are 50px, total 500px, viewport 200px.
+        // Follow-tail should anchor to the end.
+        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
+            view.clone().into_any_element()
+        });
+
+        // The scroll should be at the bottom: the last visible items fill the
+        // 200px viewport from the end of 500px of content (offset 300px).
+        let offset = state.logical_scroll_top();
+        assert_eq!(offset.item_ix, 6);
+        assert_eq!(offset.offset_in_item, px(0.));
+        assert!(state.is_following_tail());
+
+        // Simulate items growing (e.g. streaming content makes each item taller).
+        // 10 items Γ— 80px = 800px total.
+        item_height.set(80);
+        state.remeasure();
+
+        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
+            view.into_any_element()
+        });
+
+        // After growth, follow-tail should have re-anchored to the new end.
+        // 800px total βˆ’ 200px viewport = 600px offset β†’ item 7 at offset 40px,
+        // but follow-tail anchors to item_count (10), and layout walks back to
+        // fill 200px, landing at item 7 (7 Γ— 80 = 560, 800 βˆ’ 560 = 240 > 200,
+        // so item 8: 8 Γ— 80 = 640, 800 βˆ’ 640 = 160 < 200 β†’ keeps walking β†’
+        // item 7: offset = 800 βˆ’ 200 = 600, item_ix = 600/80 = 7, remainder 40).
+        let offset = state.logical_scroll_top();
+        assert_eq!(offset.item_ix, 7);
+        assert_eq!(offset.offset_in_item, px(40.));
+        assert!(state.is_following_tail());
+    }
+
+    #[gpui::test]
+    fn test_follow_tail_disengages_on_user_scroll(cx: &mut TestAppContext) {
+        let cx = cx.add_empty_window();
+
+        // 10 items Γ— 50px = 500px total, 200px viewport.
+        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
+
+        struct TestView(ListState);
+        impl Render for TestView {
+            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+                list(self.0.clone(), |_, _, _| {
+                    div().h(px(50.)).w_full().into_any()
+                })
+                .w_full()
+                .h_full()
+            }
+        }
+
+        state.set_follow_tail(true);
+
+        // Paint with follow-tail β€” scroll anchored to the bottom.
+        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, cx| {
+            cx.new(|_| TestView(state.clone())).into_any_element()
+        });
+        assert!(state.is_following_tail());
+
+        // Simulate the user scrolling up.
+        // This should disengage follow-tail.
+        cx.simulate_event(ScrollWheelEvent {
+            position: point(px(50.), px(100.)),
+            delta: ScrollDelta::Pixels(point(px(0.), px(100.))),
+            ..Default::default()
+        });
+
+        assert!(
+            !state.is_following_tail(),
+            "follow-tail should disengage when the user scrolls toward the start"
+        );
+    }
+
+    #[gpui::test]
+    fn test_follow_tail_disengages_on_scrollbar_reposition(cx: &mut TestAppContext) {
+        let cx = cx.add_empty_window();
+
+        // 10 items Γ— 50px = 500px total, 200px viewport.
+        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
+
+        struct TestView(ListState);
+        impl Render for TestView {
+            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+                list(self.0.clone(), |_, _, _| {
+                    div().h(px(50.)).w_full().into_any()
+                })
+                .w_full()
+                .h_full()
+            }
+        }
+
+        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
+
+        state.set_follow_tail(true);
+
+        // Paint with follow-tail β€” scroll anchored to the bottom.
+        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
+            view.clone().into_any_element()
+        });
+        assert!(state.is_following_tail());
+
+        // Simulate the scrollbar moving the viewport to the middle.
+        // `set_offset_from_scrollbar` accepts a positive distance from the start.
+        state.set_offset_from_scrollbar(point(px(0.), px(150.)));
+
+        let offset = state.logical_scroll_top();
+        assert_eq!(offset.item_ix, 3);
+        assert_eq!(offset.offset_in_item, px(0.));
+        assert!(
+            !state.is_following_tail(),
+            "follow-tail should disengage when the scrollbar manually repositions the list"
+        );
+
+        // A subsequent draw should preserve the user's manual position instead
+        // of snapping back to the end.
+        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
+            view.into_any_element()
+        });
+
+        let offset = state.logical_scroll_top();
+        assert_eq!(offset.item_ix, 3);
+        assert_eq!(offset.offset_in_item, px(0.));
+    }
+
+    #[gpui::test]
+    fn test_set_follow_tail_snaps_to_bottom(cx: &mut TestAppContext) {
+        let cx = cx.add_empty_window();
+
+        // 10 items Γ— 50px = 500px total, 200px viewport.
+        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
+
+        struct TestView(ListState);
+        impl Render for TestView {
+            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+                list(self.0.clone(), |_, _, _| {
+                    div().h(px(50.)).w_full().into_any()
+                })
+                .w_full()
+                .h_full()
+            }
+        }
+
+        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
+
+        // Scroll to the middle of the list (item 3).
+        state.scroll_to(gpui::ListOffset {
+            item_ix: 3,
+            offset_in_item: px(0.),
+        });
+
+        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
+            view.clone().into_any_element()
+        });
+
+        let offset = state.logical_scroll_top();
+        assert_eq!(offset.item_ix, 3);
+        assert_eq!(offset.offset_in_item, px(0.));
+        assert!(!state.is_following_tail());
+
+        // Enable follow-tail β€” this should immediately snap the scroll anchor
+        // to the end, like the user just sent a prompt.
+        state.set_follow_tail(true);
+
+        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
+            view.into_any_element()
+        });
+
+        // After paint, scroll should be at the bottom.
+        // 500px total βˆ’ 200px viewport = 300px offset β†’ item 6, offset 0.
+        let offset = state.logical_scroll_top();
+        assert_eq!(offset.item_ix, 6);
+        assert_eq!(offset.offset_in_item, px(0.));
+        assert!(state.is_following_tail());
+    }
+
+    #[gpui::test]
+    fn test_bottom_aligned_scrollbar_offset_at_end(cx: &mut TestAppContext) {
+        let cx = cx.add_empty_window();
+
+        const ITEMS: usize = 10;
+        const ITEM_SIZE: f32 = 50.0;
+
+        let state = ListState::new(
+            ITEMS,
+            crate::ListAlignment::Bottom,
+            px(ITEMS as f32 * ITEM_SIZE),
+        );
+
+        struct TestView(ListState);
+        impl Render for TestView {
+            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+                list(self.0.clone(), |_, _, _| {
+                    div().h(px(ITEM_SIZE)).w_full().into_any()
+                })
+                .w_full()
+                .h_full()
+            }
+        }
+
+        cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| {
+            cx.new(|_| TestView(state.clone())).into_any_element()
+        });
+
+        // Bottom-aligned lists start pinned to the end: logical_scroll_top returns
+        // item_ix == item_count, meaning no explicit scroll position has been set.
+        assert_eq!(state.logical_scroll_top().item_ix, ITEMS);
+
+        let max_offset = state.max_offset_for_scrollbar();
+        let scroll_offset = state.scroll_px_offset_for_scrollbar();
+
+        assert_eq!(
+            -scroll_offset.y, max_offset.y,
+            "scrollbar offset ({}) should equal max offset ({}) when list is pinned to bottom",
+            -scroll_offset.y, max_offset.y,
+        );
+    }
 }

crates/grammars/Cargo.toml πŸ”—

@@ -0,0 +1,60 @@
+[package]
+name = "grammars"
+version = "0.1.0"
+edition = "2024"
+publish = false
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/grammars.rs"
+
+[dependencies]
+language_core.workspace = true
+rust-embed.workspace = true
+anyhow.workspace = true
+toml.workspace = true
+util.workspace = true
+
+tree-sitter = { workspace = true, optional = true }
+tree-sitter-bash = { workspace = true, optional = true }
+tree-sitter-c = { workspace = true, optional = true }
+tree-sitter-cpp = { workspace = true, optional = true }
+tree-sitter-css = { workspace = true, optional = true }
+tree-sitter-diff = { workspace = true, optional = true }
+tree-sitter-gitcommit = { workspace = true, optional = true }
+tree-sitter-go = { workspace = true, optional = true }
+tree-sitter-go-mod = { workspace = true, optional = true }
+tree-sitter-gowork = { workspace = true, optional = true }
+tree-sitter-jsdoc = { workspace = true, optional = true }
+tree-sitter-json = { workspace = true, optional = true }
+tree-sitter-md = { workspace = true, optional = true }
+tree-sitter-python = { workspace = true, optional = true }
+tree-sitter-regex = { workspace = true, optional = true }
+tree-sitter-rust = { workspace = true, optional = true }
+tree-sitter-typescript = { workspace = true, optional = true }
+tree-sitter-yaml = { workspace = true, optional = true }
+
+[features]
+load-grammars = [
+    "tree-sitter",
+    "tree-sitter-bash",
+    "tree-sitter-c",
+    "tree-sitter-cpp",
+    "tree-sitter-css",
+    "tree-sitter-diff",
+    "tree-sitter-gitcommit",
+    "tree-sitter-go",
+    "tree-sitter-go-mod",
+    "tree-sitter-gowork",
+    "tree-sitter-jsdoc",
+    "tree-sitter-json",
+    "tree-sitter-md",
+    "tree-sitter-python",
+    "tree-sitter-regex",
+    "tree-sitter-rust",
+    "tree-sitter-typescript",
+    "tree-sitter-yaml",
+]
+test-support = ["load-grammars"]

crates/grammars/src/grammars.rs πŸ”—

@@ -0,0 +1,108 @@
+use anyhow::Context as _;
+use language_core::{LanguageConfig, LanguageQueries, QUERY_FILENAME_PREFIXES};
+use rust_embed::RustEmbed;
+use util::asset_str;
+
+#[derive(RustEmbed)]
+#[folder = "src/"]
+#[exclude = "*.rs"]
+struct GrammarDir;
+
+/// Register all built-in native tree-sitter grammars with the provided registration function.
+///
+/// Each grammar is registered as a `(&str, tree_sitter_language::LanguageFn)` pair.
+/// This must be called before loading language configs/queries.
+#[cfg(feature = "load-grammars")]
+pub fn native_grammars() -> Vec<(&'static str, tree_sitter::Language)> {
+    vec![
+        ("bash", tree_sitter_bash::LANGUAGE.into()),
+        ("c", tree_sitter_c::LANGUAGE.into()),
+        ("cpp", tree_sitter_cpp::LANGUAGE.into()),
+        ("css", tree_sitter_css::LANGUAGE.into()),
+        ("diff", tree_sitter_diff::LANGUAGE.into()),
+        ("go", tree_sitter_go::LANGUAGE.into()),
+        ("gomod", tree_sitter_go_mod::LANGUAGE.into()),
+        ("gowork", tree_sitter_gowork::LANGUAGE.into()),
+        ("jsdoc", tree_sitter_jsdoc::LANGUAGE.into()),
+        ("json", tree_sitter_json::LANGUAGE.into()),
+        ("jsonc", tree_sitter_json::LANGUAGE.into()),
+        ("markdown", tree_sitter_md::LANGUAGE.into()),
+        ("markdown-inline", tree_sitter_md::INLINE_LANGUAGE.into()),
+        ("python", tree_sitter_python::LANGUAGE.into()),
+        ("regex", tree_sitter_regex::LANGUAGE.into()),
+        ("rust", tree_sitter_rust::LANGUAGE.into()),
+        ("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
+        (
+            "typescript",
+            tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
+        ),
+        ("yaml", tree_sitter_yaml::LANGUAGE.into()),
+        ("gitcommit", tree_sitter_gitcommit::LANGUAGE.into()),
+    ]
+}
+
+/// Load and parse the `config.toml` for a given language name.
+pub fn load_config(name: &str) -> LanguageConfig {
+    let config_toml = String::from_utf8(
+        GrammarDir::get(&format!("{}/config.toml", name))
+            .unwrap_or_else(|| panic!("missing config for language {:?}", name))
+            .data
+            .to_vec(),
+    )
+    .unwrap();
+
+    let config: LanguageConfig = ::toml::from_str(&config_toml)
+        .with_context(|| format!("failed to load config.toml for language {name:?}"))
+        .unwrap();
+
+    config
+}
+
+/// Load and parse the `config.toml` for a given language name, stripping fields
+/// that require grammar support when grammars are not loaded.
+pub fn load_config_for_feature(name: &str, grammars_loaded: bool) -> LanguageConfig {
+    let config = load_config(name);
+
+    if grammars_loaded {
+        config
+    } else {
+        LanguageConfig {
+            name: config.name,
+            matcher: config.matcher,
+            jsx_tag_auto_close: config.jsx_tag_auto_close,
+            ..Default::default()
+        }
+    }
+}
+
+/// Get a raw embedded file by path (relative to `src/`).
+///
+/// Returns the file data as bytes, or `None` if the file does not exist.
+pub fn get_file(path: &str) -> Option<rust_embed::EmbeddedFile> {
+    GrammarDir::get(path)
+}
+
+/// Load all `.scm` query files for a given language name into a `LanguageQueries`.
+///
+/// Multiple `.scm` files with the same prefix (e.g. `highlights.scm` and
+/// `highlights_extra.scm`) are concatenated together with their contents appended.
+pub fn load_queries(name: &str) -> LanguageQueries {
+    let mut result = LanguageQueries::default();
+    for path in GrammarDir::iter() {
+        if let Some(remainder) = path.strip_prefix(name).and_then(|p| p.strip_prefix('/')) {
+            if !remainder.ends_with(".scm") {
+                continue;
+            }
+            for (prefix, query) in QUERY_FILENAME_PREFIXES {
+                if remainder.starts_with(prefix) {
+                    let contents = asset_str::<GrammarDir>(path.as_ref());
+                    match query(&mut result) {
+                        None => *query(&mut result) = Some(contents),
+                        Some(existing) => existing.to_mut().push_str(contents.as_ref()),
+                    }
+                }
+            }
+        }
+    }
+    result
+}

crates/icons/src/icons.rs πŸ”—

@@ -233,11 +233,6 @@ pub enum IconName {
     Star,
     StarFilled,
     Stop,
-    SweepAi,
-    SweepAiDisabled,
-    SweepAiDown,
-    SweepAiError,
-    SweepAiUp,
     Tab,
     Terminal,
     TerminalAlt,
@@ -249,6 +244,8 @@ pub enum IconName {
     ThreadFromSummary,
     ThreadsSidebarLeftClosed,
     ThreadsSidebarLeftOpen,
+    ThreadsSidebarRightClosed,
+    ThreadsSidebarRightOpen,
     ThumbsDown,
     ThumbsUp,
     TodoComplete,

crates/keymap_editor/src/keymap_editor.rs πŸ”—

@@ -24,6 +24,7 @@ use gpui::{
     actions, anchored, deferred, div,
 };
 use language::{Language, LanguageConfig, ToOffset as _};
+
 use notifications::status_toast::{StatusToast, ToastIcon};
 use project::{CompletionDisplayOptions, Project};
 use settings::{
@@ -2405,9 +2406,10 @@ impl RenderOnce for SyntaxHighlightedText {
             }
 
             let mut run_style = text_style.clone();
-            if let Some(highlight_style) = highlight_id.style(syntax_theme) {
+            if let Some(highlight_style) = syntax_theme.get(highlight_id).cloned() {
                 run_style = run_style.highlight(highlight_style);
             }
+
             // add the highlighted range
             runs.push(run_style.to_run(highlight_range.len()));
             offset = highlight_range.end;

crates/language/Cargo.toml πŸ”—

@@ -40,6 +40,7 @@ globset.workspace = true
 gpui.workspace = true
 http_client.workspace = true
 imara-diff.workspace = true
+language_core.workspace = true
 itertools.workspace = true
 log.workspace = true
 lsp.workspace = true
@@ -48,7 +49,6 @@ postage.workspace = true
 rand = { workspace = true, optional = true }
 regex.workspace = true
 rpc.workspace = true
-schemars.workspace = true
 semver.workspace = true
 serde.workspace = true
 serde_json.workspace = true
@@ -101,6 +101,11 @@ toml.workspace = true
 unindent.workspace = true
 util = { workspace = true, features = ["test-support"] }
 zlog.workspace = true
+criterion.workspace = true
+
+[[bench]]
+name = "highlight_map"
+harness = false
 
 [package.metadata.cargo-machete]
 ignored = ["tracing"]

crates/language/benches/highlight_map.rs πŸ”—

@@ -0,0 +1,144 @@
+use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
+use gpui::rgba;
+use language::build_highlight_map;
+use theme::SyntaxTheme;
+
+fn syntax_theme(highlight_names: &[&str]) -> SyntaxTheme {
+    SyntaxTheme::new(highlight_names.iter().enumerate().map(|(i, name)| {
+        let r = ((i * 37) % 256) as u8;
+        let g = ((i * 53) % 256) as u8;
+        let b = ((i * 71) % 256) as u8;
+        let color = rgba(u32::from_be_bytes([r, g, b, 0xff]));
+        (name.to_string(), color.into())
+    }))
+}
+
+static SMALL_THEME_KEYS: &[&str] = &[
+    "comment", "function", "keyword", "string", "type", "variable",
+];
+
+static LARGE_THEME_KEYS: &[&str] = &[
+    "attribute",
+    "boolean",
+    "comment",
+    "comment.doc",
+    "constant",
+    "constant.builtin",
+    "constructor",
+    "embedded",
+    "emphasis",
+    "emphasis.strong",
+    "function",
+    "function.builtin",
+    "function.method",
+    "function.method.builtin",
+    "function.special.definition",
+    "keyword",
+    "keyword.control",
+    "keyword.control.conditional",
+    "keyword.control.import",
+    "keyword.control.repeat",
+    "keyword.control.return",
+    "keyword.modifier",
+    "keyword.operator",
+    "label",
+    "link_text",
+    "link_uri",
+    "number",
+    "operator",
+    "property",
+    "punctuation",
+    "punctuation.bracket",
+    "punctuation.delimiter",
+    "punctuation.list_marker",
+    "punctuation.special",
+    "string",
+    "string.escape",
+    "string.regex",
+    "string.special",
+    "string.special.symbol",
+    "tag",
+    "text.literal",
+    "title",
+    "type",
+    "type.builtin",
+    "type.super",
+    "variable",
+    "variable.builtin",
+    "variable.member",
+    "variable.parameter",
+    "variable.special",
+];
+
+static SMALL_CAPTURE_NAMES: &[&str] = &[
+    "function",
+    "keyword",
+    "string.escape",
+    "type.builtin",
+    "variable.builtin",
+];
+
+static LARGE_CAPTURE_NAMES: &[&str] = &[
+    "attribute",
+    "boolean",
+    "comment",
+    "comment.doc",
+    "constant",
+    "constant.builtin",
+    "constructor",
+    "function",
+    "function.builtin",
+    "function.method",
+    "keyword",
+    "keyword.control",
+    "keyword.control.conditional",
+    "keyword.control.import",
+    "keyword.modifier",
+    "keyword.operator",
+    "label",
+    "number",
+    "operator",
+    "property",
+    "punctuation.bracket",
+    "punctuation.delimiter",
+    "punctuation.special",
+    "string",
+    "string.escape",
+    "string.regex",
+    "string.special",
+    "tag",
+    "type",
+    "type.builtin",
+    "variable",
+    "variable.builtin",
+    "variable.member",
+    "variable.parameter",
+];
+
+fn bench_build_highlight_map(c: &mut Criterion) {
+    let mut group = c.benchmark_group("build_highlight_map");
+
+    for (capture_label, capture_names) in [
+        ("small_captures", SMALL_CAPTURE_NAMES as &[&str]),
+        ("large_captures", LARGE_CAPTURE_NAMES as &[&str]),
+    ] {
+        for (theme_label, theme_keys) in [
+            ("small_theme", SMALL_THEME_KEYS as &[&str]),
+            ("large_theme", LARGE_THEME_KEYS as &[&str]),
+        ] {
+            let theme = syntax_theme(theme_keys);
+            group.bench_with_input(
+                BenchmarkId::new(capture_label, theme_label),
+                &(capture_names, &theme),
+                |b, (capture_names, theme)| {
+                    b.iter(|| build_highlight_map(black_box(capture_names), black_box(theme)));
+                },
+            );
+        }
+    }
+
+    group.finish();
+}
+
+criterion_group!(benches, bench_build_highlight_map);
+criterion_main!(benches);

crates/language/src/buffer.rs πŸ”—

@@ -16,11 +16,10 @@ use crate::{
     unified_diff_with_offsets,
 };
 pub use crate::{
-    Grammar, Language, LanguageRegistry,
-    diagnostic_set::DiagnosticSet,
-    highlight_map::{HighlightId, HighlightMap},
+    Grammar, HighlightId, HighlightMap, Language, LanguageRegistry, diagnostic_set::DiagnosticSet,
     proto,
 };
+
 use anyhow::{Context as _, Result};
 use clock::Lamport;
 pub use clock::ReplicaId;
@@ -33,10 +32,8 @@ use gpui::{
     Task, TextStyle,
 };
 
-use lsp::{LanguageServerId, NumberOrString};
+use lsp::LanguageServerId;
 use parking_lot::Mutex;
-use serde::{Deserialize, Serialize};
-use serde_json::Value;
 use settings::WorktreeId;
 use smallvec::SmallVec;
 use smol::future::yield_now;
@@ -252,57 +249,6 @@ struct SelectionSet {
     lamport_timestamp: clock::Lamport,
 }
 
-/// A diagnostic associated with a certain range of a buffer.
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
-pub struct Diagnostic {
-    /// The name of the service that produced this diagnostic.
-    pub source: Option<String>,
-    /// The ID provided by the dynamic registration that produced this diagnostic.
-    pub registration_id: Option<SharedString>,
-    /// A machine-readable code that identifies this diagnostic.
-    pub code: Option<NumberOrString>,
-    pub code_description: Option<lsp::Uri>,
-    /// Whether this diagnostic is a hint, warning, or error.
-    pub severity: DiagnosticSeverity,
-    /// The human-readable message associated with this diagnostic.
-    pub message: String,
-    /// The human-readable message (in markdown format)
-    pub markdown: Option<String>,
-    /// An id that identifies the group to which this diagnostic belongs.
-    ///
-    /// When a language server produces a diagnostic with
-    /// one or more associated diagnostics, those diagnostics are all
-    /// assigned a single group ID.
-    pub group_id: usize,
-    /// Whether this diagnostic is the primary diagnostic for its group.
-    ///
-    /// In a given group, the primary diagnostic is the top-level diagnostic
-    /// returned by the language server. The non-primary diagnostics are the
-    /// associated diagnostics.
-    pub is_primary: bool,
-    /// Whether this diagnostic is considered to originate from an analysis of
-    /// files on disk, as opposed to any unsaved buffer contents. This is a
-    /// property of a given diagnostic source, and is configured for a given
-    /// language server via the [`LspAdapter::disk_based_diagnostic_sources`](crate::LspAdapter::disk_based_diagnostic_sources) method
-    /// for the language server.
-    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.
 #[derive(Clone, Debug, PartialEq)]
 pub enum Operation {
@@ -749,7 +695,7 @@ impl HighlightedTextBuilder {
 
             if let Some(highlight_style) = chunk
                 .syntax_highlight_id
-                .and_then(|id| id.style(syntax_theme))
+                .and_then(|id| syntax_theme.get(id).cloned())
             {
                 let highlight_style = override_style.map_or(highlight_style, |override_style| {
                     highlight_style.highlight(override_style)
@@ -4551,7 +4497,8 @@ impl BufferSnapshot {
                 let style = chunk
                     .syntax_highlight_id
                     .zip(theme)
-                    .and_then(|(highlight, theme)| highlight.style(theme));
+                    .and_then(|(highlight, theme)| theme.get(highlight).cloned());
+
                 if let Some(style) = style {
                     let start = text.len();
                     let end = start + chunk.text.len();
@@ -5836,27 +5783,6 @@ impl operation_queue::Operation for Operation {
     }
 }
 
-impl Default for Diagnostic {
-    fn default() -> Self {
-        Self {
-            source: Default::default(),
-            source_kind: DiagnosticSourceKind::Other,
-            code: None,
-            code_description: None,
-            severity: DiagnosticSeverity::ERROR,
-            message: Default::default(),
-            markdown: None,
-            group_id: 0,
-            is_primary: false,
-            is_disk_based: false,
-            is_unnecessary: false,
-            underline: true,
-            data: None,
-            registration_id: None,
-        }
-    }
-}
-
 impl IndentSize {
     /// Returns an [`IndentSize`] representing the given spaces.
     pub fn spaces(len: u32) -> Self {

crates/language/src/highlight_map.rs πŸ”—

@@ -1,114 +0,0 @@
-use gpui::HighlightStyle;
-use std::sync::Arc;
-use theme::SyntaxTheme;
-
-#[derive(Clone, Debug)]
-pub struct HighlightMap(Arc<[HighlightId]>);
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub struct HighlightId(pub u32);
-
-const DEFAULT_SYNTAX_HIGHLIGHT_ID: HighlightId = HighlightId(u32::MAX);
-
-impl HighlightMap {
-    pub(crate) fn new(capture_names: &[&str], theme: &SyntaxTheme) -> Self {
-        // For each capture name in the highlight query, find the longest
-        // key in the theme's syntax styles that matches all of the
-        // dot-separated components of the capture name.
-        HighlightMap(
-            capture_names
-                .iter()
-                .map(|capture_name| {
-                    theme
-                        .highlights
-                        .iter()
-                        .enumerate()
-                        .filter_map(|(i, (key, _))| {
-                            let mut len = 0;
-                            let capture_parts = capture_name.split('.');
-                            for key_part in key.split('.') {
-                                if capture_parts.clone().any(|part| part == key_part) {
-                                    len += 1;
-                                } else {
-                                    return None;
-                                }
-                            }
-                            Some((i, len))
-                        })
-                        .max_by_key(|(_, len)| *len)
-                        .map_or(DEFAULT_SYNTAX_HIGHLIGHT_ID, |(i, _)| HighlightId(i as u32))
-                })
-                .collect(),
-        )
-    }
-
-    pub fn get(&self, capture_id: u32) -> HighlightId {
-        self.0
-            .get(capture_id as usize)
-            .copied()
-            .unwrap_or(DEFAULT_SYNTAX_HIGHLIGHT_ID)
-    }
-}
-
-impl HighlightId {
-    pub const TABSTOP_INSERT_ID: HighlightId = HighlightId(u32::MAX - 1);
-    pub const TABSTOP_REPLACE_ID: HighlightId = HighlightId(u32::MAX - 2);
-
-    pub(crate) fn is_default(&self) -> bool {
-        *self == DEFAULT_SYNTAX_HIGHLIGHT_ID
-    }
-
-    pub fn style(&self, theme: &SyntaxTheme) -> Option<HighlightStyle> {
-        theme.highlights.get(self.0 as usize).map(|entry| entry.1)
-    }
-
-    pub fn name<'a>(&self, theme: &'a SyntaxTheme) -> Option<&'a str> {
-        theme.highlights.get(self.0 as usize).map(|e| e.0.as_str())
-    }
-}
-
-impl Default for HighlightMap {
-    fn default() -> Self {
-        Self(Arc::new([]))
-    }
-}
-
-impl Default for HighlightId {
-    fn default() -> Self {
-        DEFAULT_SYNTAX_HIGHLIGHT_ID
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use gpui::rgba;
-
-    #[test]
-    fn test_highlight_map() {
-        let theme = SyntaxTheme {
-            highlights: [
-                ("function", rgba(0x100000ff)),
-                ("function.method", rgba(0x200000ff)),
-                ("function.async", rgba(0x300000ff)),
-                ("variable.builtin.self.rust", rgba(0x400000ff)),
-                ("variable.builtin", rgba(0x500000ff)),
-                ("variable", rgba(0x600000ff)),
-            ]
-            .iter()
-            .map(|(name, color)| (name.to_string(), (*color).into()))
-            .collect(),
-        };
-
-        let capture_names = &[
-            "function.special",
-            "function.async.rust",
-            "variable.builtin.self",
-        ];
-
-        let map = HighlightMap::new(capture_names, &theme);
-        assert_eq!(map.get(0).name(&theme), Some("function"));
-        assert_eq!(map.get(1).name(&theme), Some("function.async"));
-        assert_eq!(map.get(2).name(&theme), Some("variable.builtin"));
-    }
-}

crates/language/src/language.rs πŸ”—

@@ -7,9 +7,10 @@
 //!
 //! Notably we do *not* assign a single language to a single file; in real world a single file can consist of multiple programming languages - HTML is a good example of that - and `language` crate tends to reflect that status quo in its API.
 mod buffer;
+mod diagnostic;
 mod diagnostic_set;
-mod highlight_map;
 mod language_registry;
+
 pub mod language_settings;
 mod manifest;
 pub mod modeline;
@@ -23,17 +24,30 @@ mod toolchain;
 #[cfg(test)]
 pub mod buffer_tests;
 
-use crate::language_settings::SoftWrap;
 pub use crate::language_settings::{AutoIndentMode, EditPredictionsMode, IndentGuideSettings};
 use anyhow::{Context as _, Result};
 use async_trait::async_trait;
-use collections::{HashMap, HashSet, IndexSet};
+use collections::{HashMap, HashSet};
 use futures::Future;
 use futures::future::LocalBoxFuture;
 use futures::lock::OwnedMutexGuard;
-use gpui::{App, AsyncApp, Entity, SharedString};
-pub use highlight_map::HighlightMap;
+use gpui::{App, AsyncApp, Entity};
 use http_client::HttpClient;
+
+pub use language_core::highlight_map::{HighlightId, HighlightMap};
+
+pub use language_core::{
+    BlockCommentConfig, BracketPair, BracketPairConfig, BracketPairContent, BracketsConfig,
+    BracketsPatternConfig, CodeLabel, CodeLabelBuilder, DebugVariablesConfig, DebuggerTextObject,
+    DecreaseIndentConfig, Grammar, GrammarId, HighlightsConfig, ImportsConfig, IndentConfig,
+    InjectionConfig, InjectionPatternConfig, JsxTagAutoCloseConfig, LanguageConfig,
+    LanguageConfigOverride, LanguageId, LanguageMatcher, OrderedListConfig, OutlineConfig,
+    Override, OverrideConfig, OverrideEntry, PromptResponseContext, RedactionConfig,
+    RunnableCapture, RunnableConfig, SoftWrap, Symbol, TaskListConfig, TextObject,
+    TextObjectConfig, ToLspPosition, WrapCharactersConfig,
+    auto_indent_using_last_non_empty_line_default, deserialize_regex, deserialize_regex_vec,
+    regex_json_schema, regex_vec_json_schema, serialize_regex,
+};
 pub use language_registry::{
     LanguageName, LanguageServerStatusUpdate, LoadedLanguage, ServerHealth,
 };
@@ -44,13 +58,10 @@ pub use manifest::{ManifestDelegate, ManifestName, ManifestProvider, ManifestQue
 pub use modeline::{ModelineSettings, parse_modeline};
 use parking_lot::Mutex;
 use regex::Regex;
-use schemars::{JsonSchema, SchemaGenerator, json_schema};
 use semver::Version;
-use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
 use serde_json::Value;
 use settings::WorktreeId;
 use smol::future::FutureExt as _;
-use std::num::NonZeroU32;
 use std::{
     ffi::OsStr,
     fmt::Debug,
@@ -59,10 +70,7 @@ use std::{
     ops::{DerefMut, Range},
     path::{Path, PathBuf},
     str,
-    sync::{
-        Arc, LazyLock,
-        atomic::{AtomicUsize, Ordering::SeqCst},
-    },
+    sync::{Arc, LazyLock},
 };
 use syntax_map::{QueryCursorHandle, SyntaxSnapshot};
 use task::RunnableTag;
@@ -77,12 +85,12 @@ pub use toolchain::{
     LanguageToolchainStore, LocalLanguageToolchainStore, Toolchain, ToolchainList, ToolchainLister,
     ToolchainMetadata, ToolchainScope,
 };
-use tree_sitter::{self, Query, QueryCursor, WasmStore, wasmtime};
+use tree_sitter::{self, QueryCursor, WasmStore, wasmtime};
 use util::rel_path::RelPath;
-use util::serde::default_true;
 
 pub use buffer::Operation;
 pub use buffer::*;
+pub use diagnostic::{Diagnostic, DiagnosticSourceKind};
 pub use diagnostic_set::{DiagnosticEntry, DiagnosticEntryRef, DiagnosticGroup};
 pub use language_registry::{
     AvailableLanguage, BinaryStatus, LanguageNotFound, LanguageQueries, LanguageRegistry,
@@ -96,6 +104,16 @@ pub use syntax_map::{
 pub use text::{AnchorRangeExt, LineEnding};
 pub use tree_sitter::{Node, Parser, Tree, TreeCursor};
 
+pub(crate) fn to_settings_soft_wrap(value: language_core::SoftWrap) -> settings::SoftWrap {
+    match value {
+        language_core::SoftWrap::None => settings::SoftWrap::None,
+        language_core::SoftWrap::PreferLine => settings::SoftWrap::PreferLine,
+        language_core::SoftWrap::EditorWidth => settings::SoftWrap::EditorWidth,
+        language_core::SoftWrap::PreferredLineLength => settings::SoftWrap::PreferredLineLength,
+        language_core::SoftWrap::Bounded => settings::SoftWrap::Bounded,
+    }
+}
+
 static QUERY_CURSORS: Mutex<Vec<QueryCursor>> = Mutex::new(vec![]);
 static PARSERS: Mutex<Vec<Parser>> = Mutex::new(vec![]);
 
@@ -125,8 +143,6 @@ where
     func(cursor.deref_mut())
 }
 
-static NEXT_LANGUAGE_ID: AtomicUsize = AtomicUsize::new(0);
-static NEXT_GRAMMAR_ID: AtomicUsize = AtomicUsize::new(0);
 static WASM_ENGINE: LazyLock<wasmtime::Engine> = LazyLock::new(|| {
     wasmtime::Engine::new(&wasmtime::Config::new()).expect("Failed to create Wasmtime engine")
 });
@@ -188,26 +204,12 @@ pub static PLAIN_TEXT: LazyLock<Arc<Language>> = LazyLock::new(|| {
     ))
 });
 
-/// Types that represent a position in a buffer, and can be converted into
-/// an LSP position, to send to a language server.
-pub trait ToLspPosition {
-    /// Converts the value into an LSP position.
-    fn to_lsp_position(self) -> lsp::Position;
-}
-
 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
 pub struct Location {
     pub buffer: Entity<Buffer>,
     pub range: Range<Anchor>,
 }
 
-#[derive(Debug, Clone)]
-pub struct Symbol {
-    pub name: String,
-    pub kind: lsp::SymbolKind,
-    pub container_name: Option<String>,
-}
-
 type ServerBinaryCache = futures::lock::Mutex<Option<(bool, LanguageServerBinary)>>;
 type DownloadableLanguageServerBinary = LocalBoxFuture<'static, Result<LanguageServerBinary>>;
 pub type LanguageServerBinaryLocations = LocalBoxFuture<
@@ -292,14 +294,12 @@ impl CachedLspAdapter {
         &self,
         params: &mut lsp::PublishDiagnosticsParams,
         server_id: LanguageServerId,
-        existing_diagnostics: Option<&'_ Buffer>,
     ) {
-        self.adapter
-            .process_diagnostics(params, server_id, existing_diagnostics)
+        self.adapter.process_diagnostics(params, server_id)
     }
 
-    pub fn retain_old_diagnostic(&self, previous_diagnostic: &Diagnostic, cx: &App) -> bool {
-        self.adapter.retain_old_diagnostic(previous_diagnostic, cx)
+    pub fn retain_old_diagnostic(&self, previous_diagnostic: &Diagnostic) -> bool {
+        self.adapter.retain_old_diagnostic(previous_diagnostic)
     }
 
     pub fn underline_diagnostic(&self, diagnostic: &lsp::Diagnostic) -> bool {
@@ -397,31 +397,14 @@ pub trait LspAdapterDelegate: Send + Sync {
     async fn try_exec(&self, binary: LanguageServerBinary) -> Result<()>;
 }
 
-/// Context provided to LSP adapters when a user responds to a ShowMessageRequest prompt.
-/// This allows adapters to intercept preference selections (like "Always" or "Never")
-/// and potentially persist them to Zed's settings.
-#[derive(Debug, Clone)]
-pub struct PromptResponseContext {
-    /// The original message shown to the user
-    pub message: String,
-    /// The action (button) the user selected
-    pub selected_action: lsp::MessageActionItem,
-}
-
 #[async_trait(?Send)]
 pub trait LspAdapter: 'static + Send + Sync + DynLspInstaller {
     fn name(&self) -> LanguageServerName;
 
-    fn process_diagnostics(
-        &self,
-        _: &mut lsp::PublishDiagnosticsParams,
-        _: LanguageServerId,
-        _: Option<&'_ Buffer>,
-    ) {
-    }
+    fn process_diagnostics(&self, _: &mut lsp::PublishDiagnosticsParams, _: LanguageServerId) {}
 
     /// When processing new `lsp::PublishDiagnosticsParams` diagnostics, whether to retain previous one(s) or not.
-    fn retain_old_diagnostic(&self, _previous_diagnostic: &Diagnostic, _cx: &App) -> bool {
+    fn retain_old_diagnostic(&self, _previous_diagnostic: &Diagnostic) -> bool {
         false
     }
 
@@ -812,300 +795,6 @@ where
     }
 }
 
-#[derive(Clone, Debug, Default, PartialEq, Eq)]
-pub struct CodeLabel {
-    /// The text to display.
-    pub text: String,
-    /// Syntax highlighting runs.
-    pub runs: Vec<(Range<usize>, HighlightId)>,
-    /// The portion of the text that should be used in fuzzy filtering.
-    pub filter_range: Range<usize>,
-}
-
-#[derive(Clone, Debug, Default, PartialEq, Eq)]
-pub struct CodeLabelBuilder {
-    /// The text to display.
-    text: String,
-    /// Syntax highlighting runs.
-    runs: Vec<(Range<usize>, HighlightId)>,
-    /// The portion of the text that should be used in fuzzy filtering.
-    filter_range: Range<usize>,
-}
-
-#[derive(Clone, Deserialize, JsonSchema, Debug)]
-pub struct LanguageConfig {
-    /// Human-readable name of the language.
-    pub name: LanguageName,
-    /// The name of this language for a Markdown code fence block
-    pub code_fence_block_name: Option<Arc<str>>,
-    /// Alternative language names that Jupyter kernels may report for this language.
-    /// Used when a kernel's `language` field differs from Zed's language name.
-    /// For example, the Nu extension would set this to `["nushell"]`.
-    #[serde(default)]
-    pub kernel_language_names: Vec<Arc<str>>,
-    // The name of the grammar in a WASM bundle (experimental).
-    pub grammar: Option<Arc<str>>,
-    /// The criteria for matching this language to a given file.
-    #[serde(flatten)]
-    pub matcher: LanguageMatcher,
-    /// List of bracket types in a language.
-    #[serde(default)]
-    pub brackets: BracketPairConfig,
-    /// 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")]
-    pub auto_indent_using_last_non_empty_line: bool,
-    // Whether indentation of pasted content should be adjusted based on the context.
-    #[serde(default)]
-    pub auto_indent_on_paste: Option<bool>,
-    /// A regex that is used to determine whether the indentation level should be
-    /// increased in the following line.
-    #[serde(default, deserialize_with = "deserialize_regex")]
-    #[schemars(schema_with = "regex_json_schema")]
-    pub increase_indent_pattern: Option<Regex>,
-    /// A regex that is used to determine whether the indentation level should be
-    /// decreased in the following line.
-    #[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.
-    #[serde(default)]
-    pub autoclose_before: String,
-    /// A placeholder used internally by Semantic Index.
-    #[serde(default)]
-    pub collapsed_placeholder: String,
-    /// A line comment string that is inserted in e.g. `toggle comments` action.
-    /// A language can have multiple flavours of line comments. All of the provided line comments are
-    /// used for comment continuations on the next line, but only the first one is used for Editor::ToggleComments.
-    #[serde(default)]
-    pub line_comments: Vec<Arc<str>>,
-    /// Delimiters and configuration for recognizing and formatting block comments.
-    #[serde(default)]
-    pub block_comment: Option<BlockCommentConfig>,
-    /// Delimiters and configuration for recognizing and formatting documentation comments.
-    #[serde(default, alias = "documentation")]
-    pub documentation_comment: Option<BlockCommentConfig>,
-    /// List markers that are inserted unchanged on newline (e.g., `- `, `* `, `+ `).
-    #[serde(default)]
-    pub unordered_list: Vec<Arc<str>>,
-    /// Configuration for ordered lists with auto-incrementing numbers on newline (e.g., `1. ` becomes `2. `).
-    #[serde(default)]
-    pub ordered_list: Vec<OrderedListConfig>,
-    /// Configuration for task lists where multiple markers map to a single continuation prefix (e.g., `- [x] ` continues as `- [ ] `).
-    #[serde(default)]
-    pub task_list: Option<TaskListConfig>,
-    /// A list of additional regex patterns that should be treated as prefixes
-    /// for creating boundaries during rewrapping, ensuring content from one
-    /// prefixed section doesn't merge with another (e.g., markdown list items).
-    /// By default, Zed treats as paragraph and comment prefixes as boundaries.
-    #[serde(default, deserialize_with = "deserialize_regex_vec")]
-    #[schemars(schema_with = "regex_vec_json_schema")]
-    pub rewrap_prefixes: Vec<Regex>,
-    /// A list of language servers that are allowed to run on subranges of a given language.
-    #[serde(default)]
-    pub scope_opt_in_language_servers: Vec<LanguageServerName>,
-    #[serde(default)]
-    pub overrides: HashMap<String, LanguageConfigOverride>,
-    /// A list of characters that Zed should treat as word characters for the
-    /// purpose of features that operate on word boundaries, like 'move to next word end'
-    /// or a whole-word search in buffer search.
-    #[serde(default)]
-    pub word_characters: HashSet<char>,
-    /// Whether to indent lines using tab characters, as opposed to multiple
-    /// spaces.
-    #[serde(default)]
-    pub hard_tabs: Option<bool>,
-    /// How many columns a tab should occupy.
-    #[serde(default)]
-    #[schemars(range(min = 1, max = 128))]
-    pub tab_size: Option<NonZeroU32>,
-    /// How to soft-wrap long lines of text.
-    #[serde(default)]
-    pub soft_wrap: Option<SoftWrap>,
-    /// When set, selections can be wrapped using prefix/suffix pairs on both sides.
-    #[serde(default)]
-    pub wrap_characters: Option<WrapCharactersConfig>,
-    /// The name of a Prettier parser that will be used for this language when no file path is available.
-    /// If there's a parser name in the language settings, that will be used instead.
-    #[serde(default)]
-    pub prettier_parser_name: Option<String>,
-    /// If true, this language is only for syntax highlighting via an injection into other
-    /// languages, but should not appear to the user as a distinct language.
-    #[serde(default)]
-    pub hidden: bool,
-    /// If configured, this language contains JSX style tags, and should support auto-closing of those tags.
-    #[serde(default)]
-    pub jsx_tag_auto_close: Option<JsxTagAutoCloseConfig>,
-    /// A list of characters that Zed should treat as word characters for completion queries.
-    #[serde(default)]
-    pub completion_query_characters: HashSet<char>,
-    /// A list of characters that Zed should treat as word characters for linked edit operations.
-    #[serde(default)]
-    pub linked_edit_characters: HashSet<char>,
-    /// A list of preferred debuggers for this language.
-    #[serde(default)]
-    pub debuggers: IndexSet<SharedString>,
-    /// A list of import namespace segments that aren't expected to appear in file paths. For
-    /// example, "super" and "crate" in Rust.
-    #[serde(default)]
-    pub ignored_import_segments: HashSet<Arc<str>>,
-    /// Regular expression that matches substrings to omit from import paths, to make the paths more
-    /// similar to how they are specified when imported. For example, "/mod\.rs$" or "/__init__\.py$".
-    #[serde(default, deserialize_with = "deserialize_regex")]
-    #[schemars(schema_with = "regex_json_schema")]
-    pub import_path_strip_regex: Option<Regex>,
-}
-
-impl LanguageConfig {
-    pub const FILE_NAME: &str = "config.toml";
-
-    pub fn load(config_path: impl AsRef<Path>) -> Result<Self> {
-        let config = std::fs::read_to_string(config_path.as_ref())?;
-        toml::from_str(&config).map_err(Into::into)
-    }
-}
-
-#[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>,
-}
-
-/// Configuration for continuing ordered lists with auto-incrementing numbers.
-#[derive(Clone, Debug, Deserialize, JsonSchema)]
-pub struct OrderedListConfig {
-    /// A regex pattern with a capture group for the number portion (e.g., `(\\d+)\\. `).
-    pub pattern: String,
-    /// A format string where `{1}` is replaced with the incremented number (e.g., `{1}. `).
-    pub format: String,
-}
-
-/// Configuration for continuing task lists on newline.
-#[derive(Clone, Debug, Deserialize, JsonSchema)]
-pub struct TaskListConfig {
-    /// The list markers to match (e.g., `- [ ] `, `- [x] `).
-    pub prefixes: Vec<Arc<str>>,
-    /// The marker to insert when continuing the list on a new line (e.g., `- [ ] `).
-    pub continuation: Arc<str>,
-}
-
-#[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)]
-pub struct LanguageMatcher {
-    /// Given a list of `LanguageConfig`'s, the language of a file can be determined based on the path extension matching any of the `path_suffixes`.
-    #[serde(default)]
-    pub path_suffixes: Vec<String>,
-    /// A regex pattern that determines whether the language should be assigned to a file or not.
-    #[serde(
-        default,
-        serialize_with = "serialize_regex",
-        deserialize_with = "deserialize_regex"
-    )]
-    #[schemars(schema_with = "regex_json_schema")]
-    pub first_line_pattern: Option<Regex>,
-    /// Alternative names for this language used in vim/emacs modelines.
-    /// These are matched case-insensitively against the `mode` (emacs) or
-    /// `filetype`/`ft` (vim) specified in the modeline.
-    #[serde(default)]
-    pub modeline_aliases: Vec<String>,
-}
-
-/// The configuration for JSX tag auto-closing.
-#[derive(Clone, Deserialize, JsonSchema, Debug)]
-pub struct JsxTagAutoCloseConfig {
-    /// The name of the node for a opening tag
-    pub open_tag_node_name: String,
-    /// The name of the node for an closing tag
-    pub close_tag_node_name: String,
-    /// The name of the node for a complete element with children for open and close tags
-    pub jsx_element_node_name: String,
-    /// The name of the node found within both opening and closing
-    /// tags that describes the tag name
-    pub tag_name_node_name: String,
-    /// Alternate Node names for tag names.
-    /// Specifically needed as TSX represents the name in `<Foo.Bar>`
-    /// as `member_expression` rather than `identifier` as usual
-    #[serde(default)]
-    pub tag_name_node_name_alternates: Vec<String>,
-    /// Some grammars are smart enough to detect a closing tag
-    /// that is not valid i.e. doesn't match it's corresponding
-    /// opening tag or does not have a corresponding opening tag
-    /// This should be set to the name of the node for invalid
-    /// closing tags if the grammar contains such a node, otherwise
-    /// detecting already closed tags will not work properly
-    #[serde(default)]
-    pub erroneous_close_tag_node_name: Option<String>,
-    /// See above for erroneous_close_tag_node_name for details
-    /// This should be set if the node used for the tag name
-    /// within erroneous closing tags is different from the
-    /// normal tag name node name
-    #[serde(default)]
-    pub erroneous_close_tag_name_node_name: Option<String>,
-}
-
-/// The configuration for block comments for this language.
-#[derive(Clone, Debug, JsonSchema, PartialEq)]
-pub struct BlockCommentConfig {
-    /// A start tag of block comment.
-    pub start: Arc<str>,
-    /// A end tag of block comment.
-    pub end: Arc<str>,
-    /// A character to add as a prefix when a new line is added to a block comment.
-    pub prefix: Arc<str>,
-    /// A indent to add for prefix and end line upon new line.
-    #[schemars(range(min = 1, max = 128))]
-    pub tab_size: u32,
-}
-
-impl<'de> Deserialize<'de> for BlockCommentConfig {
-    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-    where
-        D: Deserializer<'de>,
-    {
-        #[derive(Deserialize)]
-        #[serde(untagged)]
-        enum BlockCommentConfigHelper {
-            New {
-                start: Arc<str>,
-                end: Arc<str>,
-                prefix: Arc<str>,
-                tab_size: u32,
-            },
-            Old([Arc<str>; 2]),
-        }
-
-        match BlockCommentConfigHelper::deserialize(deserializer)? {
-            BlockCommentConfigHelper::New {
-                start,
-                end,
-                prefix,
-                tab_size,
-            } => Ok(BlockCommentConfig {
-                start,
-                end,
-                prefix,
-                tab_size,
-            }),
-            BlockCommentConfigHelper::Old([start, end]) => Ok(BlockCommentConfig {
-                start,
-                end,
-                prefix: "".into(),
-                tab_size: 0,
-            }),
-        }
-    }
-}
-
 /// 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.
@@ -1115,148 +804,6 @@ pub struct LanguageScope {
     override_id: Option<u32>,
 }
 
-#[derive(Clone, Deserialize, Default, Debug, JsonSchema)]
-pub struct LanguageConfigOverride {
-    #[serde(default)]
-    pub line_comments: Override<Vec<Arc<str>>>,
-    #[serde(default)]
-    pub block_comment: Override<BlockCommentConfig>,
-    #[serde(skip)]
-    pub disabled_bracket_ixs: Vec<u16>,
-    #[serde(default)]
-    pub word_characters: Override<HashSet<char>>,
-    #[serde(default)]
-    pub completion_query_characters: Override<HashSet<char>>,
-    #[serde(default)]
-    pub linked_edit_characters: Override<HashSet<char>>,
-    #[serde(default)]
-    pub opt_into_language_servers: Vec<LanguageServerName>,
-    #[serde(default)]
-    pub prefer_label_for_snippet: Option<bool>,
-}
-
-#[derive(Clone, Deserialize, Debug, Serialize, JsonSchema)]
-#[serde(untagged)]
-pub enum Override<T> {
-    Remove { remove: bool },
-    Set(T),
-}
-
-impl<T> Default for Override<T> {
-    fn default() -> Self {
-        Override::Remove { remove: false }
-    }
-}
-
-impl<T> Override<T> {
-    fn as_option<'a>(this: Option<&'a Self>, original: Option<&'a T>) -> Option<&'a T> {
-        match this {
-            Some(Self::Set(value)) => Some(value),
-            Some(Self::Remove { remove: true }) => None,
-            Some(Self::Remove { remove: false }) | None => original,
-        }
-    }
-}
-
-impl Default for LanguageConfig {
-    fn default() -> Self {
-        Self {
-            name: LanguageName::new_static(""),
-            code_fence_block_name: None,
-            kernel_language_names: Default::default(),
-            grammar: None,
-            matcher: LanguageMatcher::default(),
-            brackets: Default::default(),
-            auto_indent_using_last_non_empty_line: auto_indent_using_last_non_empty_line_default(),
-            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(),
-            documentation_comment: Default::default(),
-            unordered_list: Default::default(),
-            ordered_list: Default::default(),
-            task_list: Default::default(),
-            rewrap_prefixes: Default::default(),
-            scope_opt_in_language_servers: Default::default(),
-            overrides: Default::default(),
-            word_characters: Default::default(),
-            collapsed_placeholder: Default::default(),
-            hard_tabs: None,
-            tab_size: None,
-            soft_wrap: None,
-            wrap_characters: None,
-            prettier_parser_name: None,
-            hidden: false,
-            jsx_tag_auto_close: None,
-            completion_query_characters: Default::default(),
-            linked_edit_characters: Default::default(),
-            debuggers: Default::default(),
-            ignored_import_segments: Default::default(),
-            import_path_strip_regex: None,
-        }
-    }
-}
-
-#[derive(Clone, Debug, Deserialize, JsonSchema)]
-pub struct WrapCharactersConfig {
-    /// Opening token split into a prefix and suffix. The first caret goes
-    /// after the prefix (i.e., between prefix and suffix).
-    pub start_prefix: String,
-    pub start_suffix: String,
-    /// Closing token split into a prefix and suffix. The second caret goes
-    /// after the prefix (i.e., between prefix and suffix).
-    pub end_prefix: String,
-    pub end_suffix: String,
-}
-
-fn auto_indent_using_last_non_empty_line_default() -> bool {
-    true
-}
-
-fn deserialize_regex<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Regex>, D::Error> {
-    let source = Option::<String>::deserialize(d)?;
-    if let Some(source) = source {
-        Ok(Some(regex::Regex::new(&source).map_err(de::Error::custom)?))
-    } else {
-        Ok(None)
-    }
-}
-
-fn regex_json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
-    json_schema!({
-        "type": "string"
-    })
-}
-
-fn serialize_regex<S>(regex: &Option<Regex>, serializer: S) -> Result<S::Ok, S::Error>
-where
-    S: Serializer,
-{
-    match regex {
-        Some(regex) => serializer.serialize_str(regex.as_str()),
-        None => serializer.serialize_none(),
-    }
-}
-
-fn deserialize_regex_vec<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<Regex>, D::Error> {
-    let sources = Vec::<String>::deserialize(d)?;
-    sources
-        .into_iter()
-        .map(|source| regex::Regex::new(&source))
-        .collect::<Result<_, _>>()
-        .map_err(de::Error::custom)
-}
-
-fn regex_vec_json_schema(_: &mut SchemaGenerator) -> schemars::Schema {
-    json_schema!({
-        "type": "array",
-        "items": { "type": "string" }
-    })
-}
-
 #[doc(hidden)]
 #[cfg(any(test, feature = "test-support"))]
 pub struct FakeLspAdapter {
@@ -1279,79 +826,6 @@ pub struct FakeLspAdapter {
     >,
 }
 
-/// Configuration of handling bracket pairs for a given language.
-///
-/// This struct includes settings for defining which pairs of characters are considered brackets and
-/// also specifies any language-specific scopes where these pairs should be ignored for bracket matching purposes.
-#[derive(Clone, Debug, Default, JsonSchema)]
-#[schemars(with = "Vec::<BracketPairContent>")]
-pub struct BracketPairConfig {
-    /// A list of character pairs that should be treated as brackets in the context of a given language.
-    pub pairs: Vec<BracketPair>,
-    /// A list of tree-sitter scopes for which a given bracket should not be active.
-    /// N-th entry in `[Self::disabled_scopes_by_bracket_ix]` contains a list of disabled scopes for an n-th entry in `[Self::pairs]`
-    pub disabled_scopes_by_bracket_ix: Vec<Vec<String>>,
-}
-
-impl BracketPairConfig {
-    pub fn is_closing_brace(&self, c: char) -> bool {
-        self.pairs.iter().any(|pair| pair.end.starts_with(c))
-    }
-}
-
-#[derive(Deserialize, JsonSchema)]
-pub struct BracketPairContent {
-    #[serde(flatten)]
-    pub bracket_pair: BracketPair,
-    #[serde(default)]
-    pub not_in: Vec<String>,
-}
-
-impl<'de> Deserialize<'de> for BracketPairConfig {
-    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
-    where
-        D: Deserializer<'de>,
-    {
-        let result = Vec::<BracketPairContent>::deserialize(deserializer)?;
-        let (brackets, disabled_scopes_by_bracket_ix) = result
-            .into_iter()
-            .map(|entry| (entry.bracket_pair, entry.not_in))
-            .unzip();
-
-        Ok(BracketPairConfig {
-            pairs: brackets,
-            disabled_scopes_by_bracket_ix,
-        })
-    }
-}
-
-/// Describes a single bracket pair and how an editor should react to e.g. inserting
-/// an opening bracket or to a newline character insertion in between `start` and `end` characters.
-#[derive(Clone, Debug, Default, Deserialize, PartialEq, JsonSchema)]
-pub struct BracketPair {
-    /// Starting substring for a bracket.
-    pub start: String,
-    /// Ending substring for a bracket.
-    pub end: String,
-    /// True if `end` should be automatically inserted right after `start` characters.
-    pub close: bool,
-    /// True if selected text should be surrounded by `start` and `end` characters.
-    #[serde(default = "default_true")]
-    pub surround: bool,
-    /// True if an extra newline should be inserted while the cursor is in the middle
-    /// of that bracket pair.
-    pub newline: bool,
-}
-
-#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
-pub struct LanguageId(usize);
-
-impl LanguageId {
-    pub(crate) fn new() -> Self {
-        Self(NEXT_LANGUAGE_ID.fetch_add(1, SeqCst))
-    }
-}
-
 pub struct Language {
     pub(crate) id: LanguageId,
     pub(crate) config: LanguageConfig,
@@ -1361,184 +835,6 @@ pub struct Language {
     pub(crate) manifest_name: Option<ManifestName>,
 }
 
-#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
-pub struct GrammarId(pub usize);
-
-impl GrammarId {
-    pub(crate) fn new() -> Self {
-        Self(NEXT_GRAMMAR_ID.fetch_add(1, SeqCst))
-    }
-}
-
-pub struct Grammar {
-    id: GrammarId,
-    pub ts_language: tree_sitter::Language,
-    pub(crate) error_query: Option<Query>,
-    pub highlights_config: Option<HighlightsConfig>,
-    pub(crate) brackets_config: Option<BracketsConfig>,
-    pub(crate) redactions_config: Option<RedactionConfig>,
-    pub(crate) runnable_config: Option<RunnableConfig>,
-    pub(crate) indents_config: Option<IndentConfig>,
-    pub outline_config: Option<OutlineConfig>,
-    pub text_object_config: Option<TextObjectConfig>,
-    pub(crate) injection_config: Option<InjectionConfig>,
-    pub(crate) override_config: Option<OverrideConfig>,
-    pub(crate) debug_variables_config: Option<DebugVariablesConfig>,
-    pub(crate) imports_config: Option<ImportsConfig>,
-    pub(crate) highlight_map: Mutex<HighlightMap>,
-}
-
-pub struct HighlightsConfig {
-    pub query: Query,
-    pub identifier_capture_indices: Vec<u32>,
-}
-
-struct IndentConfig {
-    query: Query,
-    indent_capture_ix: u32,
-    start_capture_ix: Option<u32>,
-    end_capture_ix: Option<u32>,
-    outdent_capture_ix: Option<u32>,
-    suffixed_start_captures: HashMap<u32, SharedString>,
-}
-
-pub struct OutlineConfig {
-    pub query: Query,
-    pub item_capture_ix: u32,
-    pub name_capture_ix: u32,
-    pub context_capture_ix: Option<u32>,
-    pub extra_context_capture_ix: Option<u32>,
-    pub open_capture_ix: Option<u32>,
-    pub close_capture_ix: Option<u32>,
-    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,
-    AroundFunction,
-    InsideClass,
-    AroundClass,
-    InsideComment,
-    AroundComment,
-}
-
-impl TextObject {
-    pub fn from_capture_name(name: &str) -> Option<TextObject> {
-        match name {
-            "function.inside" => Some(TextObject::InsideFunction),
-            "function.around" => Some(TextObject::AroundFunction),
-            "class.inside" => Some(TextObject::InsideClass),
-            "class.around" => Some(TextObject::AroundClass),
-            "comment.inside" => Some(TextObject::InsideComment),
-            "comment.around" => Some(TextObject::AroundComment),
-            _ => None,
-        }
-    }
-
-    pub fn around(&self) -> Option<Self> {
-        match self {
-            TextObject::InsideFunction => Some(TextObject::AroundFunction),
-            TextObject::InsideClass => Some(TextObject::AroundClass),
-            TextObject::InsideComment => Some(TextObject::AroundComment),
-            _ => None,
-        }
-    }
-}
-
-pub struct TextObjectConfig {
-    pub query: Query,
-    pub text_objects_by_capture_ix: Vec<(u32, TextObject)>,
-}
-
-struct InjectionConfig {
-    query: Query,
-    content_capture_ix: u32,
-    language_capture_ix: Option<u32>,
-    patterns: Vec<InjectionPatternConfig>,
-}
-
-struct RedactionConfig {
-    pub query: Query,
-    pub redaction_capture_ix: u32,
-}
-
-#[derive(Clone, Debug, PartialEq)]
-enum RunnableCapture {
-    Named(SharedString),
-    Run,
-}
-
-struct RunnableConfig {
-    pub query: Query,
-    /// A mapping from capture indice to capture kind
-    pub extra_captures: Vec<RunnableCapture>,
-}
-
-struct OverrideConfig {
-    query: Query,
-    values: HashMap<u32, OverrideEntry>,
-}
-
-#[derive(Debug)]
-struct OverrideEntry {
-    name: String,
-    range_is_inclusive: bool,
-    value: LanguageConfigOverride,
-}
-
-#[derive(Default, Clone)]
-struct InjectionPatternConfig {
-    language: Option<Box<str>>,
-    combined: bool,
-}
-
-#[derive(Debug)]
-struct BracketsConfig {
-    query: Query,
-    open_capture_ix: u32,
-    close_capture_ix: u32,
-    patterns: Vec<BracketsPatternConfig>,
-}
-
-#[derive(Clone, Debug, Default)]
-struct BracketsPatternConfig {
-    newline_only: bool,
-    rainbow_exclude: bool,
-}
-
-pub struct DebugVariablesConfig {
-    pub query: Query,
-    pub objects_by_capture_ix: Vec<(u32, DebuggerTextObject)>,
-}
-
-pub struct ImportsConfig {
-    pub query: Query,
-    pub import_ix: u32,
-    pub name_ix: Option<u32>,
-    pub namespace_ix: Option<u32>,
-    pub source_ix: Option<u32>,
-    pub list_ix: Option<u32>,
-    pub wildcard_ix: Option<u32>,
-    pub alias_ix: Option<u32>,
-}
-
 impl Language {
     pub fn new(config: LanguageConfig, ts_language: Option<tree_sitter::Language>) -> Self {
         Self::new_with_id(LanguageId::new(), config, ts_language)
@@ -1556,25 +852,7 @@ impl Language {
         Self {
             id,
             config,
-            grammar: ts_language.map(|ts_language| {
-                Arc::new(Grammar {
-                    id: GrammarId::new(),
-                    highlights_config: None,
-                    brackets_config: None,
-                    outline_config: None,
-                    text_object_config: None,
-                    indents_config: None,
-                    injection_config: None,
-                    override_config: None,
-                    redactions_config: None,
-                    runnable_config: None,
-                    error_query: Query::new(&ts_language, "(ERROR) @error").ok(),
-                    debug_variables_config: None,
-                    imports_config: None,
-                    ts_language,
-                    highlight_map: Default::default(),
-                })
-            }),
+            grammar: ts_language.map(|ts_language| Arc::new(Grammar::new(ts_language))),
             context_provider: None,
             toolchain: None,
             manifest_name: None,
@@ -1597,493 +875,99 @@ impl Language {
     }
 
     pub fn with_queries(mut self, queries: LanguageQueries) -> Result<Self> {
-        if let Some(query) = queries.highlights {
-            self = self
-                .with_highlights_query(query.as_ref())
-                .context("Error loading highlights query")?;
-        }
-        if let Some(query) = queries.brackets {
-            self = self
-                .with_brackets_query(query.as_ref())
-                .context("Error loading brackets query")?;
-        }
-        if let Some(query) = queries.indents {
-            self = self
-                .with_indents_query(query.as_ref())
-                .context("Error loading indents query")?;
-        }
-        if let Some(query) = queries.outline {
-            self = self
-                .with_outline_query(query.as_ref())
-                .context("Error loading outline query")?;
-        }
-        if let Some(query) = queries.injections {
-            self = self
-                .with_injection_query(query.as_ref())
-                .context("Error loading injection query")?;
-        }
-        if let Some(query) = queries.overrides {
-            self = self
-                .with_override_query(query.as_ref())
-                .context("Error loading override query")?;
-        }
-        if let Some(query) = queries.redactions {
-            self = self
-                .with_redaction_query(query.as_ref())
-                .context("Error loading redaction query")?;
-        }
-        if let Some(query) = queries.runnables {
-            self = self
-                .with_runnable_query(query.as_ref())
-                .context("Error loading runnables query")?;
-        }
-        if let Some(query) = queries.text_objects {
-            self = self
-                .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")?;
-        }
-        if let Some(query) = queries.imports {
-            self = self
-                .with_imports_query(query.as_ref())
-                .context("Error loading imports query")?;
+        if let Some(grammar) = self.grammar.take() {
+            let grammar =
+                Arc::try_unwrap(grammar).map_err(|_| anyhow::anyhow!("cannot mutate grammar"))?;
+            let grammar = grammar.with_queries(queries, &mut self.config)?;
+            self.grammar = Some(Arc::new(grammar));
         }
         Ok(self)
     }
 
-    pub fn with_highlights_query(mut self, source: &str) -> Result<Self> {
-        let grammar = self.grammar_mut()?;
-        let query = Query::new(&grammar.ts_language, source)?;
-
-        let mut identifier_capture_indices = Vec::new();
-        for name in [
-            "variable",
-            "constant",
-            "constructor",
-            "function",
-            "function.method",
-            "function.method.call",
-            "function.special",
-            "property",
-            "type",
-            "type.interface",
-        ] {
-            identifier_capture_indices.extend(query.capture_index_for_name(name));
-        }
-
-        grammar.highlights_config = Some(HighlightsConfig {
-            query,
-            identifier_capture_indices,
-        });
-
-        Ok(self)
+    pub fn with_highlights_query(self, source: &str) -> Result<Self> {
+        self.with_grammar_query(|grammar| grammar.with_highlights_query(source))
     }
 
-    pub fn with_runnable_query(mut self, source: &str) -> Result<Self> {
-        let grammar = self.grammar_mut()?;
-
-        let query = Query::new(&grammar.ts_language, source)?;
-        let extra_captures: Vec<_> = query
-            .capture_names()
-            .iter()
-            .map(|&name| match name {
-                "run" => RunnableCapture::Run,
-                name => RunnableCapture::Named(name.to_string().into()),
-            })
-            .collect();
-
-        grammar.runnable_config = Some(RunnableConfig {
-            extra_captures,
-            query,
-        });
-
-        Ok(self)
+    pub fn with_runnable_query(self, source: &str) -> Result<Self> {
+        self.with_grammar_query(|grammar| grammar.with_runnable_query(source))
     }
 
-    pub fn with_outline_query(mut self, source: &str) -> Result<Self> {
-        let query = Query::new(&self.expect_grammar()?.ts_language, source)?;
-        let mut item_capture_ix = 0;
-        let mut name_capture_ix = 0;
-        let mut context_capture_ix = None;
-        let mut extra_context_capture_ix = None;
-        let mut open_capture_ix = None;
-        let mut close_capture_ix = None;
-        let mut annotation_capture_ix = None;
-        if populate_capture_indices(
-            &query,
-            &self.config.name,
-            "outline",
-            &[],
-            &mut [
-                Capture::Required("item", &mut item_capture_ix),
-                Capture::Required("name", &mut name_capture_ix),
-                Capture::Optional("context", &mut context_capture_ix),
-                Capture::Optional("context.extra", &mut extra_context_capture_ix),
-                Capture::Optional("open", &mut open_capture_ix),
-                Capture::Optional("close", &mut close_capture_ix),
-                Capture::Optional("annotation", &mut annotation_capture_ix),
-            ],
-        ) {
-            self.grammar_mut()?.outline_config = Some(OutlineConfig {
-                query,
-                item_capture_ix,
-                name_capture_ix,
-                context_capture_ix,
-                extra_context_capture_ix,
-                open_capture_ix,
-                close_capture_ix,
-                annotation_capture_ix,
-            });
-        }
-        Ok(self)
+    pub fn with_outline_query(self, source: &str) -> Result<Self> {
+        self.with_grammar_query_and_name(|grammar, name| grammar.with_outline_query(source, name))
     }
 
-    pub fn with_text_object_query(mut self, source: &str) -> Result<Self> {
-        let query = Query::new(&self.expect_grammar()?.ts_language, source)?;
-
-        let mut text_objects_by_capture_ix = Vec::new();
-        for (ix, name) in query.capture_names().iter().enumerate() {
-            if let Some(text_object) = TextObject::from_capture_name(name) {
-                text_objects_by_capture_ix.push((ix as u32, text_object));
-            } else {
-                log::warn!(
-                    "unrecognized capture name '{}' in {} textobjects TreeSitter query",
-                    name,
-                    self.config.name,
-                );
-            }
-        }
-
-        self.grammar_mut()?.text_object_config = Some(TextObjectConfig {
-            query,
-            text_objects_by_capture_ix,
-        });
-        Ok(self)
+    pub fn with_text_object_query(self, source: &str) -> Result<Self> {
+        self.with_grammar_query_and_name(|grammar, name| {
+            grammar.with_text_object_query(source, name)
+        })
     }
 
-    pub fn with_debug_variables_query(mut self, source: &str) -> Result<Self> {
-        let query = Query::new(&self.expect_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));
-            } else {
-                log::warn!(
-                    "unrecognized capture name '{}' in {} debugger TreeSitter query",
-                    name,
-                    self.config.name,
-                );
-            }
-        }
+    pub fn with_debug_variables_query(self, source: &str) -> Result<Self> {
+        self.with_grammar_query_and_name(|grammar, name| {
+            grammar.with_debug_variables_query(source, name)
+        })
+    }
 
-        self.grammar_mut()?.debug_variables_config = Some(DebugVariablesConfig {
-            query,
-            objects_by_capture_ix,
-        });
-        Ok(self)
+    pub fn with_imports_query(self, source: &str) -> Result<Self> {
+        self.with_grammar_query_and_name(|grammar, name| grammar.with_imports_query(source, name))
     }
 
-    pub fn with_imports_query(mut self, source: &str) -> Result<Self> {
-        let query = Query::new(&self.expect_grammar()?.ts_language, source)?;
-
-        let mut import_ix = 0;
-        let mut name_ix = None;
-        let mut namespace_ix = None;
-        let mut source_ix = None;
-        let mut list_ix = None;
-        let mut wildcard_ix = None;
-        let mut alias_ix = None;
-        if populate_capture_indices(
-            &query,
-            &self.config.name,
-            "imports",
-            &[],
-            &mut [
-                Capture::Required("import", &mut import_ix),
-                Capture::Optional("name", &mut name_ix),
-                Capture::Optional("namespace", &mut namespace_ix),
-                Capture::Optional("source", &mut source_ix),
-                Capture::Optional("list", &mut list_ix),
-                Capture::Optional("wildcard", &mut wildcard_ix),
-                Capture::Optional("alias", &mut alias_ix),
-            ],
-        ) {
-            self.grammar_mut()?.imports_config = Some(ImportsConfig {
-                query,
-                import_ix,
-                name_ix,
-                namespace_ix,
-                source_ix,
-                list_ix,
-                wildcard_ix,
-                alias_ix,
-            });
-        }
-        return Ok(self);
-    }
-
-    pub fn with_brackets_query(mut self, source: &str) -> Result<Self> {
-        let query = Query::new(&self.expect_grammar()?.ts_language, source)?;
-        let mut open_capture_ix = 0;
-        let mut close_capture_ix = 0;
-        if populate_capture_indices(
-            &query,
-            &self.config.name,
-            "brackets",
-            &[],
-            &mut [
-                Capture::Required("open", &mut open_capture_ix),
-                Capture::Required("close", &mut close_capture_ix),
-            ],
-        ) {
-            let patterns = (0..query.pattern_count())
-                .map(|ix| {
-                    let mut config = BracketsPatternConfig::default();
-                    for setting in query.property_settings(ix) {
-                        let setting_key = setting.key.as_ref();
-                        if setting_key == "newline.only" {
-                            config.newline_only = true
-                        }
-                        if setting_key == "rainbow.exclude" {
-                            config.rainbow_exclude = true
-                        }
-                    }
-                    config
-                })
-                .collect();
-            self.grammar_mut()?.brackets_config = Some(BracketsConfig {
-                query,
-                open_capture_ix,
-                close_capture_ix,
-                patterns,
-            });
-        }
-        Ok(self)
+    pub fn with_brackets_query(self, source: &str) -> Result<Self> {
+        self.with_grammar_query_and_name(|grammar, name| grammar.with_brackets_query(source, name))
     }
 
-    pub fn with_indents_query(mut self, source: &str) -> Result<Self> {
-        let query = Query::new(&self.expect_grammar()?.ts_language, source)?;
-        let mut indent_capture_ix = 0;
-        let mut start_capture_ix = None;
-        let mut end_capture_ix = None;
-        let mut outdent_capture_ix = None;
-        if populate_capture_indices(
-            &query,
-            &self.config.name,
-            "indents",
-            &["start."],
-            &mut [
-                Capture::Required("indent", &mut indent_capture_ix),
-                Capture::Optional("start", &mut start_capture_ix),
-                Capture::Optional("end", &mut end_capture_ix),
-                Capture::Optional("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());
-                }
-            }
+    pub fn with_indents_query(self, source: &str) -> Result<Self> {
+        self.with_grammar_query_and_name(|grammar, name| grammar.with_indents_query(source, name))
+    }
 
-            self.grammar_mut()?.indents_config = Some(IndentConfig {
-                query,
-                indent_capture_ix,
-                start_capture_ix,
-                end_capture_ix,
-                outdent_capture_ix,
-                suffixed_start_captures,
-            });
-        }
-        Ok(self)
+    pub fn with_injection_query(self, source: &str) -> Result<Self> {
+        self.with_grammar_query_and_name(|grammar, name| grammar.with_injection_query(source, name))
     }
 
-    pub fn with_injection_query(mut self, source: &str) -> Result<Self> {
-        let query = Query::new(&self.expect_grammar()?.ts_language, source)?;
-        let mut language_capture_ix = None;
-        let mut injection_language_capture_ix = None;
-        let mut content_capture_ix = None;
-        let mut injection_content_capture_ix = None;
-        if populate_capture_indices(
-            &query,
-            &self.config.name,
-            "injections",
-            &[],
-            &mut [
-                Capture::Optional("language", &mut language_capture_ix),
-                Capture::Optional("injection.language", &mut injection_language_capture_ix),
-                Capture::Optional("content", &mut content_capture_ix),
-                Capture::Optional("injection.content", &mut injection_content_capture_ix),
-            ],
-        ) {
-            language_capture_ix = match (language_capture_ix, injection_language_capture_ix) {
-                (None, Some(ix)) => Some(ix),
-                (Some(_), Some(_)) => {
-                    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(_)) => {
-                    anyhow::bail!("both content and injection.content captures are present")
-                }
-                _ => content_capture_ix,
-            };
-            let patterns = (0..query.pattern_count())
-                .map(|ix| {
-                    let mut config = InjectionPatternConfig::default();
-                    for setting in query.property_settings(ix) {
-                        match setting.key.as_ref() {
-                            "language" | "injection.language" => {
-                                config.language.clone_from(&setting.value);
-                            }
-                            "combined" | "injection.combined" => {
-                                config.combined = true;
-                            }
-                            _ => {}
-                        }
-                    }
-                    config
-                })
-                .collect();
-            if let Some(content_capture_ix) = content_capture_ix {
-                self.grammar_mut()?.injection_config = Some(InjectionConfig {
-                    query,
-                    language_capture_ix,
-                    content_capture_ix,
-                    patterns,
-                });
-            } else {
-                log::error!(
-                    "missing required capture in injections {} TreeSitter query: \
-                    content or injection.content",
-                    &self.config.name,
-                );
-            }
+    pub fn with_override_query(mut self, source: &str) -> Result<Self> {
+        if let Some(grammar_arc) = self.grammar.take() {
+            let grammar = Arc::try_unwrap(grammar_arc)
+                .map_err(|_| anyhow::anyhow!("cannot mutate grammar"))?;
+            let grammar = grammar.with_override_query(
+                source,
+                &self.config.name,
+                &self.config.overrides,
+                &mut self.config.brackets,
+                &self.config.scope_opt_in_language_servers,
+            )?;
+            self.grammar = Some(Arc::new(grammar));
         }
         Ok(self)
     }
 
-    pub fn with_override_query(mut self, source: &str) -> anyhow::Result<Self> {
-        let query = Query::new(&self.expect_grammar()?.ts_language, source)?;
-
-        let mut override_configs_by_id = HashMap::default();
-        for (ix, mut name) in query.capture_names().iter().copied().enumerate() {
-            let mut range_is_inclusive = false;
-            if name.starts_with('_') {
-                continue;
-            }
-            if let Some(prefix) = name.strip_suffix(".inclusive") {
-                name = prefix;
-                range_is_inclusive = true;
-            }
-
-            let value = self.config.overrides.get(name).cloned().unwrap_or_default();
-            for server_name in &value.opt_into_language_servers {
-                if !self
-                    .config
-                    .scope_opt_in_language_servers
-                    .contains(server_name)
-                {
-                    util::debug_panic!(
-                        "Server {server_name:?} has been opted-in by scope {name:?} but has not been marked as an opt-in server"
-                    );
-                }
-            }
-
-            override_configs_by_id.insert(
-                ix as u32,
-                OverrideEntry {
-                    name: name.to_string(),
-                    range_is_inclusive,
-                    value,
-                },
-            );
-        }
-
-        let referenced_override_names = self.config.overrides.keys().chain(
-            self.config
-                .brackets
-                .disabled_scopes_by_bracket_ix
-                .iter()
-                .flatten(),
-        );
-
-        for referenced_name in referenced_override_names {
-            if !override_configs_by_id
-                .values()
-                .any(|entry| entry.name == *referenced_name)
-            {
-                anyhow::bail!(
-                    "language {:?} has overrides in config not in query: {referenced_name:?}",
-                    self.config.name
-                );
-            }
-        }
+    pub fn with_redaction_query(self, source: &str) -> Result<Self> {
+        self.with_grammar_query_and_name(|grammar, name| grammar.with_redaction_query(source, name))
+    }
 
-        for entry in override_configs_by_id.values_mut() {
-            entry.value.disabled_bracket_ixs = self
-                .config
-                .brackets
-                .disabled_scopes_by_bracket_ix
-                .iter()
-                .enumerate()
-                .filter_map(|(ix, disabled_scope_names)| {
-                    if disabled_scope_names.contains(&entry.name) {
-                        Some(ix as u16)
-                    } else {
-                        None
-                    }
-                })
-                .collect();
+    fn with_grammar_query(
+        mut self,
+        build: impl FnOnce(Grammar) -> Result<Grammar>,
+    ) -> Result<Self> {
+        if let Some(grammar_arc) = self.grammar.take() {
+            let grammar = Arc::try_unwrap(grammar_arc)
+                .map_err(|_| anyhow::anyhow!("cannot mutate grammar"))?;
+            self.grammar = Some(Arc::new(build(grammar)?));
         }
-
-        self.config.brackets.disabled_scopes_by_bracket_ix.clear();
-
-        let grammar = self.grammar_mut()?;
-        grammar.override_config = Some(OverrideConfig {
-            query,
-            values: override_configs_by_id,
-        });
         Ok(self)
     }
 
-    pub fn with_redaction_query(mut self, source: &str) -> anyhow::Result<Self> {
-        let query = Query::new(&self.expect_grammar()?.ts_language, source)?;
-        let mut redaction_capture_ix = 0;
-        if populate_capture_indices(
-            &query,
-            &self.config.name,
-            "redactions",
-            &[],
-            &mut [Capture::Required("redact", &mut redaction_capture_ix)],
-        ) {
-            self.grammar_mut()?.redactions_config = Some(RedactionConfig {
-                query,
-                redaction_capture_ix,
-            });
+    fn with_grammar_query_and_name(
+        mut self,
+        build: impl FnOnce(Grammar, &LanguageName) -> Result<Grammar>,
+    ) -> Result<Self> {
+        if let Some(grammar_arc) = self.grammar.take() {
+            let grammar = Arc::try_unwrap(grammar_arc)
+                .map_err(|_| anyhow::anyhow!("cannot mutate grammar"))?;
+            self.grammar = Some(Arc::new(build(grammar, &self.config.name)?));
         }
         Ok(self)
     }
 
-    fn expect_grammar(&self) -> Result<&Grammar> {
-        self.grammar
-            .as_ref()
-            .map(|grammar| grammar.as_ref())
-            .context("no grammar for language")
-    }
-
-    fn grammar_mut(&mut self) -> Result<&mut Grammar> {
-        Arc::get_mut(self.grammar.as_mut().context("no grammar for language")?)
-            .context("cannot mutate grammar")
-    }
-
     pub fn name(&self) -> LanguageName {
         self.config.name.clone()
     }

crates/language/src/language_registry.rs πŸ”—

@@ -5,6 +5,10 @@ use crate::{
 };
 use anyhow::{Context as _, Result, anyhow};
 use collections::{FxHashMap, HashMap, HashSet, hash_map};
+pub use language_core::{
+    BinaryStatus, LanguageName, LanguageQueries, LanguageServerStatusUpdate,
+    QUERY_FILENAME_PREFIXES, ServerHealth,
+};
 use settings::{AllLanguageSettingsContent, LanguageSettingsContent};
 
 use futures::{
@@ -12,15 +16,13 @@ use futures::{
     channel::{mpsc, oneshot},
 };
 use globset::GlobSet;
-use gpui::{App, BackgroundExecutor, SharedString};
+use gpui::{App, BackgroundExecutor};
 use lsp::LanguageServerId;
 use parking_lot::{Mutex, RwLock};
 use postage::watch;
-use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
+
 use smallvec::SmallVec;
 use std::{
-    borrow::{Borrow, Cow},
     cell::LazyCell,
     ffi::OsStr,
     ops::Not,
@@ -33,91 +35,6 @@ use theme::Theme;
 use unicase::UniCase;
 use util::{ResultExt, maybe, post_inc};
 
-#[derive(
-    Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
-)]
-pub struct LanguageName(pub SharedString);
-
-impl LanguageName {
-    pub fn new(s: &str) -> Self {
-        Self(SharedString::new(s))
-    }
-
-    pub fn new_static(s: &'static str) -> Self {
-        Self(SharedString::new_static(s))
-    }
-
-    pub fn from_proto(s: String) -> Self {
-        Self(SharedString::from(s))
-    }
-
-    pub fn to_proto(&self) -> String {
-        self.0.to_string()
-    }
-
-    pub fn lsp_id(&self) -> String {
-        match self.0.as_ref() {
-            "Plain Text" => "plaintext".to_string(),
-            language_name => language_name.to_lowercase(),
-        }
-    }
-}
-
-impl From<LanguageName> for SharedString {
-    fn from(value: LanguageName) -> Self {
-        value.0
-    }
-}
-
-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()
-    }
-}
-
-impl Borrow<str> for LanguageName {
-    fn borrow(&self) -> &str {
-        self.0.as_ref()
-    }
-}
-
-impl PartialEq<str> for LanguageName {
-    fn eq(&self, other: &str) -> bool {
-        self.0.as_ref() == other
-    }
-}
-
-impl PartialEq<&str> for LanguageName {
-    fn eq(&self, other: &&str) -> bool {
-        self.0.as_ref() == *other
-    }
-}
-
-impl std::fmt::Display for LanguageName {
-    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
-        write!(f, "{}", self.0)
-    }
-}
-
-impl From<&'static str> for LanguageName {
-    fn from(str: &'static str) -> Self {
-        Self(SharedString::new_static(str))
-    }
-}
-
-impl From<LanguageName> for String {
-    fn from(value: LanguageName) -> Self {
-        let value: &str = &value.0;
-        Self::from(value)
-    }
-}
-
 pub struct LanguageRegistry {
     state: RwLock<LanguageRegistryState>,
     language_server_download_dir: Option<Arc<Path>>,
@@ -153,31 +70,6 @@ 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 },
-}
-
 #[derive(Clone)]
 pub struct AvailableLanguage {
     id: LanguageId,
@@ -232,39 +124,6 @@ impl std::fmt::Display for LanguageNotFound {
     }
 }
 
-pub const QUERY_FILENAME_PREFIXES: &[(
-    &str,
-    fn(&mut LanguageQueries) -> &mut Option<Cow<'static, str>>,
-)] = &[
-    ("highlights", |q| &mut q.highlights),
-    ("brackets", |q| &mut q.brackets),
-    ("outline", |q| &mut q.outline),
-    ("indents", |q| &mut q.indents),
-    ("injections", |q| &mut q.injections),
-    ("overrides", |q| &mut q.overrides),
-    ("redactions", |q| &mut q.redactions),
-    ("runnables", |q| &mut q.runnables),
-    ("debugger", |q| &mut q.debugger),
-    ("textobjects", |q| &mut q.text_objects),
-    ("imports", |q| &mut q.imports),
-];
-
-/// Tree-sitter language queries for a given language.
-#[derive(Debug, Default)]
-pub struct LanguageQueries {
-    pub highlights: Option<Cow<'static, str>>,
-    pub brackets: Option<Cow<'static, str>>,
-    pub indents: Option<Cow<'static, str>>,
-    pub outline: Option<Cow<'static, str>>,
-    pub injections: Option<Cow<'static, str>>,
-    pub overrides: Option<Cow<'static, str>>,
-    pub redactions: Option<Cow<'static, str>>,
-    pub runnables: Option<Cow<'static, str>>,
-    pub text_objects: Option<Cow<'static, str>>,
-    pub debugger: Option<Cow<'static, str>>,
-    pub imports: Option<Cow<'static, str>>,
-}
-
 #[derive(Clone, Default)]
 struct ServerStatusSender {
     txs: Arc<Mutex<Vec<mpsc::UnboundedSender<(LanguageServerName, BinaryStatus)>>>>,
@@ -1261,7 +1120,7 @@ impl LanguageRegistryState {
             LanguageSettingsContent {
                 tab_size: language.config.tab_size,
                 hard_tabs: language.config.hard_tabs,
-                soft_wrap: language.config.soft_wrap,
+                soft_wrap: language.config.soft_wrap.map(crate::to_settings_soft_wrap),
                 auto_indent_on_paste: language.config.auto_indent_on_paste,
                 ..Default::default()
             },

crates/language/src/language_settings.rs πŸ”—

@@ -469,8 +469,6 @@ pub struct EditPredictionSettings {
     pub copilot: CopilotSettings,
     /// Settings specific to Codestral.
     pub codestral: CodestralSettings,
-    /// Settings specific to Sweep.
-    pub sweep: SweepSettings,
     /// Settings specific to Ollama.
     pub ollama: Option<OpenAiCompatibleEditPredictionSettings>,
     pub open_ai_compatible_api: Option<OpenAiCompatibleEditPredictionSettings>,
@@ -522,15 +520,6 @@ pub struct CodestralSettings {
     pub api_url: Option<String>,
 }
 
-#[derive(Clone, Debug, Default)]
-pub struct SweepSettings {
-    /// When enabled, Sweep will not store edit prediction inputs or outputs.
-    /// When disabled, Sweep may collect data including buffer contents,
-    /// diagnostics, file paths, repository names, and generated predictions
-    /// to improve the service.
-    pub privacy_mode: bool,
-}
-
 #[derive(Clone, Debug, Default)]
 pub struct OpenAiCompatibleEditPredictionSettings {
     /// Model to use for completions.
@@ -805,10 +794,6 @@ impl settings::Settings for AllLanguageSettings {
             api_url: codestral.api_url,
         };
 
-        let sweep = edit_predictions.sweep.unwrap();
-        let sweep_settings = SweepSettings {
-            privacy_mode: sweep.privacy_mode.unwrap(),
-        };
         let ollama = edit_predictions.ollama.unwrap();
         let ollama_settings = ollama
             .model
@@ -872,7 +857,6 @@ impl settings::Settings for AllLanguageSettings {
                 mode: edit_predictions_mode,
                 copilot: copilot_settings,
                 codestral: codestral_settings,
-                sweep: sweep_settings,
                 ollama: ollama_settings,
                 open_ai_compatible_api: openai_compatible_settings,
                 enabled_in_text_threads,

crates/language/src/manifest.rs πŸ”—

@@ -1,43 +1,12 @@
-use std::{borrow::Borrow, sync::Arc};
+use std::sync::Arc;
 
-use gpui::SharedString;
 use settings::WorktreeId;
 use util::rel_path::RelPath;
 
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
-pub struct ManifestName(SharedString);
+// Re-export ManifestName from language_core.
+pub use language_core::ManifestName;
 
-impl Borrow<SharedString> for ManifestName {
-    fn borrow(&self) -> &SharedString {
-        &self.0
-    }
-}
-
-impl Borrow<str> for ManifestName {
-    fn borrow(&self) -> &str {
-        &self.0
-    }
-}
-
-impl From<SharedString> for ManifestName {
-    fn from(value: SharedString) -> Self {
-        Self(value)
-    }
-}
-
-impl From<ManifestName> for SharedString {
-    fn from(value: ManifestName) -> Self {
-        value.0
-    }
-}
-
-impl AsRef<SharedString> for ManifestName {
-    fn as_ref(&self) -> &SharedString {
-        &self.0
-    }
-}
-
-/// Represents a manifest query; given a path to a file, [ManifestSearcher] is tasked with finding a path to the directory containing the manifest for that file.
+/// Represents a manifest query; given a path to a file, the manifest provider is tasked with finding a path to the directory containing the manifest for that file.
 ///
 /// Since parts of the path might have already been explored, there's an additional `depth` parameter that indicates to what ancestry level a given path should be explored.
 /// For example, given a path like `foo/bar/baz`, a depth of 2 would explore `foo/bar/baz` and `foo/bar`, but not `foo`.

crates/language/src/syntax_map.rs πŸ”—

@@ -1121,7 +1121,7 @@ impl<'a> SyntaxMapCaptures<'a> {
             let grammar_index = result
                 .grammars
                 .iter()
-                .position(|g| g.id == grammar.id())
+                .position(|g| g.id() == grammar.id())
                 .unwrap_or_else(|| {
                     result.grammars.push(grammar);
                     result.grammars.len() - 1
@@ -1265,7 +1265,7 @@ impl<'a> SyntaxMapMatches<'a> {
             let grammar_index = result
                 .grammars
                 .iter()
-                .position(|g| g.id == grammar.id())
+                .position(|g| g.id() == grammar.id())
                 .unwrap_or_else(|| {
                     result.grammars.push(grammar);
                     result.grammars.len() - 1

crates/language/src/syntax_map/syntax_map_tests.rs πŸ”—

@@ -1492,7 +1492,7 @@ fn python_lang() -> Language {
     )
     .with_queries(LanguageQueries {
         injections: Some(Cow::from(include_str!(
-            "../../../languages/src/python/injections.scm"
+            "../../../grammars/src/python/injections.scm"
         ))),
         ..Default::default()
     })

crates/language/src/toolchain.rs πŸ”—

@@ -4,95 +4,21 @@
 //! which is a set of tools used to interact with the projects written in said language.
 //! For example, a Python project can have an associated virtual environment; a Rust project can have a toolchain override.
 
-use std::{
-    path::{Path, PathBuf},
-    sync::Arc,
-};
+use std::{path::PathBuf, sync::Arc};
 
 use async_trait::async_trait;
 use collections::HashMap;
-use fs::Fs;
+
 use futures::future::BoxFuture;
-use gpui::{App, AsyncApp, SharedString};
+use gpui::{App, AsyncApp};
 use settings::WorktreeId;
 use task::ShellKind;
 use util::rel_path::RelPath;
 
-use crate::{LanguageName, ManifestName};
-
-/// Represents a single toolchain.
-#[derive(Clone, Eq, Debug)]
-pub struct Toolchain {
-    /// User-facing label
-    pub name: SharedString,
-    /// Absolute path
-    pub path: SharedString,
-    pub language_name: LanguageName,
-    /// Full toolchain data (including language-specific details)
-    pub as_json: serde_json::Value,
-}
-
-/// Declares a scope of a toolchain added by user.
-///
-/// When the user adds a toolchain, we give them an option to see that toolchain in:
-/// - All of their projects
-/// - A project they're currently in.
-/// - Only in the subproject they're currently in.
-#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
-pub enum ToolchainScope {
-    Subproject(Arc<Path>, Arc<RelPath>),
-    Project,
-    /// Available in all projects on this box. It wouldn't make sense to show suggestions across machines.
-    Global,
-}
-
-impl ToolchainScope {
-    pub fn label(&self) -> &'static str {
-        match self {
-            ToolchainScope::Subproject(_, _) => "Subproject",
-            ToolchainScope::Project => "Project",
-            ToolchainScope::Global => "Global",
-        }
-    }
-
-    pub fn description(&self) -> &'static str {
-        match self {
-            ToolchainScope::Subproject(_, _) => {
-                "Available only in the subproject you're currently in."
-            }
-            ToolchainScope::Project => "Available in all locations in your current project.",
-            ToolchainScope::Global => "Available in all of your projects on this machine.",
-        }
-    }
-}
-
-impl std::hash::Hash for Toolchain {
-    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
-        let Self {
-            name,
-            path,
-            language_name,
-            as_json: _,
-        } = self;
-        name.hash(state);
-        path.hash(state);
-        language_name.hash(state);
-    }
-}
+use crate::LanguageName;
 
-impl PartialEq for Toolchain {
-    fn eq(&self, other: &Self) -> bool {
-        let Self {
-            name,
-            path,
-            language_name,
-            as_json: _,
-        } = self;
-        // Do not use as_json for comparisons; it shouldn't impact equality, as it's not user-surfaced.
-        // Thus, there could be multiple entries that look the same in the UI.
-        (name, path, language_name).eq(&(&other.name, &other.path, &other.language_name))
-    }
-}
+// Re-export core data types from language_core.
+pub use language_core::{Toolchain, ToolchainList, ToolchainMetadata, ToolchainScope};
 
 #[async_trait]
 pub trait ToolchainLister: Send + Sync + 'static {
@@ -102,7 +28,6 @@ pub trait ToolchainLister: Send + Sync + 'static {
         worktree_root: PathBuf,
         subroot_relative_path: Arc<RelPath>,
         project_env: Option<HashMap<String, String>>,
-        fs: &dyn Fs,
     ) -> ToolchainList;
 
     /// Given a user-created toolchain, resolve lister-specific details.
@@ -111,7 +36,6 @@ pub trait ToolchainLister: Send + Sync + 'static {
         &self,
         path: PathBuf,
         project_env: Option<HashMap<String, String>>,
-        fs: &dyn Fs,
     ) -> anyhow::Result<Toolchain>;
 
     fn activation_script(
@@ -125,16 +49,6 @@ pub trait ToolchainLister: Send + Sync + 'static {
     fn meta(&self) -> ToolchainMetadata;
 }
 
-#[derive(Clone, PartialEq, Eq, Hash)]
-pub struct ToolchainMetadata {
-    /// Returns a term which we should use in UI to refer to toolchains produced by a given `[ToolchainLister]`.
-    pub term: SharedString,
-    /// A user-facing placeholder describing the semantic meaning of a path to a new toolchain.
-    pub new_toolchain_placeholder: SharedString,
-    /// The name of the manifest file for this toolchain.
-    pub manifest_name: ManifestName,
-}
-
 #[async_trait(?Send)]
 pub trait LanguageToolchainStore: Send + Sync + 'static {
     async fn active_toolchain(
@@ -168,31 +82,3 @@ impl<T: LocalLanguageToolchainStore> LanguageToolchainStore for T {
         self.active_toolchain(worktree_id, &relative_path, language_name, cx)
     }
 }
-
-type DefaultIndex = usize;
-#[derive(Default, Clone, Debug)]
-pub struct ToolchainList {
-    pub toolchains: Vec<Toolchain>,
-    pub default: Option<DefaultIndex>,
-    pub groups: Box<[(usize, SharedString)]>,
-}
-
-impl ToolchainList {
-    pub fn toolchains(&self) -> &[Toolchain] {
-        &self.toolchains
-    }
-    pub fn default_toolchain(&self) -> Option<Toolchain> {
-        self.default.and_then(|ix| self.toolchains.get(ix)).cloned()
-    }
-    pub fn group_for_index(&self, index: usize) -> Option<(usize, SharedString)> {
-        if index >= self.toolchains.len() {
-            return None;
-        }
-        let first_equal_or_greater = self
-            .groups
-            .partition_point(|(group_lower_bound, _)| group_lower_bound <= &index);
-        self.groups
-            .get(first_equal_or_greater.checked_sub(1)?)
-            .cloned()
-    }
-}

crates/language_core/Cargo.toml πŸ”—

@@ -0,0 +1,29 @@
+[package]
+name = "language_core"
+version = "0.1.0"
+edition = "2024"
+publish = false
+
+[lib]
+path = "src/language_core.rs"
+
+[dependencies]
+anyhow.workspace = true
+collections.workspace = true
+gpui.workspace = true
+log.workspace = true
+lsp.workspace = true
+parking_lot.workspace = true
+regex.workspace = true
+schemars.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+toml.workspace = true
+tree-sitter.workspace = true
+util.workspace = true
+
+[dev-dependencies]
+gpui = { workspace = true, features = ["test-support"] }
+
+[features]
+test-support = []

crates/language_core/src/code_label.rs πŸ”—

@@ -0,0 +1,122 @@
+use crate::highlight_map::HighlightId;
+use std::ops::Range;
+
+#[derive(Debug, Clone)]
+pub struct Symbol {
+    pub name: String,
+    pub kind: lsp::SymbolKind,
+    pub container_name: Option<String>,
+}
+
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct CodeLabel {
+    /// The text to display.
+    pub text: String,
+    /// Syntax highlighting runs.
+    pub runs: Vec<(Range<usize>, HighlightId)>,
+    /// The portion of the text that should be used in fuzzy filtering.
+    pub filter_range: Range<usize>,
+}
+
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct CodeLabelBuilder {
+    /// The text to display.
+    text: String,
+    /// Syntax highlighting runs.
+    runs: Vec<(Range<usize>, HighlightId)>,
+    /// The portion of the text that should be used in fuzzy filtering.
+    filter_range: Range<usize>,
+}
+
+impl CodeLabel {
+    pub fn plain(text: String, filter_text: Option<&str>) -> Self {
+        Self::filtered(text.clone(), text.len(), filter_text, Vec::new())
+    }
+
+    pub fn filtered(
+        text: String,
+        label_len: usize,
+        filter_text: Option<&str>,
+        runs: Vec<(Range<usize>, HighlightId)>,
+    ) -> Self {
+        assert!(label_len <= text.len());
+        let filter_range = filter_text
+            .and_then(|filter| text.find(filter).map(|index| index..index + filter.len()))
+            .unwrap_or(0..label_len);
+        Self::new(text, filter_range, runs)
+    }
+
+    pub fn new(
+        text: String,
+        filter_range: Range<usize>,
+        runs: Vec<(Range<usize>, HighlightId)>,
+    ) -> Self {
+        assert!(
+            text.get(filter_range.clone()).is_some(),
+            "invalid filter range"
+        );
+        runs.iter().for_each(|(range, _)| {
+            assert!(
+                text.get(range.clone()).is_some(),
+                "invalid run range with inputs. Requested range {range:?} in text '{text}'",
+            );
+        });
+        Self {
+            runs,
+            filter_range,
+            text,
+        }
+    }
+
+    pub fn text(&self) -> &str {
+        self.text.as_str()
+    }
+
+    pub fn filter_text(&self) -> &str {
+        &self.text[self.filter_range.clone()]
+    }
+}
+
+impl From<String> for CodeLabel {
+    fn from(value: String) -> Self {
+        Self::plain(value, None)
+    }
+}
+
+impl From<&str> for CodeLabel {
+    fn from(value: &str) -> Self {
+        Self::plain(value.to_string(), None)
+    }
+}
+
+impl CodeLabelBuilder {
+    pub fn respan_filter_range(&mut self, filter_text: Option<&str>) {
+        self.filter_range = filter_text
+            .and_then(|filter| {
+                self.text
+                    .find(filter)
+                    .map(|index| index..index + filter.len())
+            })
+            .unwrap_or(0..self.text.len());
+    }
+
+    pub fn push_str(&mut self, text: &str, highlight: Option<HighlightId>) {
+        let start_index = self.text.len();
+        self.text.push_str(text);
+        if let Some(highlight) = highlight {
+            let end_index = self.text.len();
+            self.runs.push((start_index..end_index, highlight));
+        }
+    }
+
+    pub fn build(mut self) -> CodeLabel {
+        if self.filter_range.end == 0 {
+            self.respan_filter_range(None);
+        }
+        CodeLabel {
+            text: self.text,
+            runs: self.runs,
+            filter_range: self.filter_range,
+        }
+    }
+}

crates/language_core/src/diagnostic.rs πŸ”—

@@ -0,0 +1,76 @@
+use gpui::SharedString;
+use lsp::{DiagnosticSeverity, NumberOrString};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+/// A diagnostic associated with a certain range of a buffer.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Diagnostic {
+    /// The name of the service that produced this diagnostic.
+    pub source: Option<String>,
+    /// The ID provided by the dynamic registration that produced this diagnostic.
+    pub registration_id: Option<SharedString>,
+    /// A machine-readable code that identifies this diagnostic.
+    pub code: Option<NumberOrString>,
+    pub code_description: Option<lsp::Uri>,
+    /// Whether this diagnostic is a hint, warning, or error.
+    pub severity: DiagnosticSeverity,
+    /// The human-readable message associated with this diagnostic.
+    pub message: String,
+    /// The human-readable message (in markdown format)
+    pub markdown: Option<String>,
+    /// An id that identifies the group to which this diagnostic belongs.
+    ///
+    /// When a language server produces a diagnostic with
+    /// one or more associated diagnostics, those diagnostics are all
+    /// assigned a single group ID.
+    pub group_id: usize,
+    /// Whether this diagnostic is the primary diagnostic for its group.
+    ///
+    /// In a given group, the primary diagnostic is the top-level diagnostic
+    /// returned by the language server. The non-primary diagnostics are the
+    /// associated diagnostics.
+    pub is_primary: bool,
+    /// Whether this diagnostic is considered to originate from an analysis of
+    /// files on disk, as opposed to any unsaved buffer contents. This is a
+    /// property of a given diagnostic source, and is configured for a given
+    /// language server via the `LspAdapter::disk_based_diagnostic_sources` method
+    /// for the language server.
+    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,
+}
+
+impl Default for Diagnostic {
+    fn default() -> Self {
+        Self {
+            source: Default::default(),
+            source_kind: DiagnosticSourceKind::Other,
+            code: None,
+            code_description: None,
+            severity: DiagnosticSeverity::ERROR,
+            message: Default::default(),
+            markdown: None,
+            group_id: 0,
+            is_primary: false,
+            is_disk_based: false,
+            is_unnecessary: false,
+            underline: true,
+            data: None,
+            registration_id: None,
+        }
+    }
+}

crates/language_core/src/grammar.rs πŸ”—

@@ -0,0 +1,821 @@
+use crate::{
+    HighlightId, HighlightMap, LanguageConfig, LanguageConfigOverride, LanguageName,
+    LanguageQueries, language_config::BracketPairConfig,
+};
+use anyhow::{Context as _, Result};
+use collections::HashMap;
+use gpui::SharedString;
+use lsp::LanguageServerName;
+use parking_lot::Mutex;
+use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
+use tree_sitter::Query;
+
+pub static NEXT_GRAMMAR_ID: AtomicUsize = AtomicUsize::new(0);
+
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
+pub struct GrammarId(pub usize);
+
+impl GrammarId {
+    pub fn new() -> Self {
+        Self(NEXT_GRAMMAR_ID.fetch_add(1, SeqCst))
+    }
+}
+
+impl Default for GrammarId {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+pub struct Grammar {
+    id: GrammarId,
+    pub ts_language: tree_sitter::Language,
+    pub error_query: Option<Query>,
+    pub highlights_config: Option<HighlightsConfig>,
+    pub brackets_config: Option<BracketsConfig>,
+    pub redactions_config: Option<RedactionConfig>,
+    pub runnable_config: Option<RunnableConfig>,
+    pub indents_config: Option<IndentConfig>,
+    pub outline_config: Option<OutlineConfig>,
+    pub text_object_config: Option<TextObjectConfig>,
+    pub injection_config: Option<InjectionConfig>,
+    pub override_config: Option<OverrideConfig>,
+    pub debug_variables_config: Option<DebugVariablesConfig>,
+    pub imports_config: Option<ImportsConfig>,
+    pub highlight_map: Mutex<HighlightMap>,
+}
+
+pub struct HighlightsConfig {
+    pub query: Query,
+    pub identifier_capture_indices: Vec<u32>,
+}
+
+pub struct IndentConfig {
+    pub query: Query,
+    pub indent_capture_ix: u32,
+    pub start_capture_ix: Option<u32>,
+    pub end_capture_ix: Option<u32>,
+    pub outdent_capture_ix: Option<u32>,
+    pub suffixed_start_captures: HashMap<u32, SharedString>,
+}
+
+pub struct OutlineConfig {
+    pub query: Query,
+    pub item_capture_ix: u32,
+    pub name_capture_ix: u32,
+    pub context_capture_ix: Option<u32>,
+    pub extra_context_capture_ix: Option<u32>,
+    pub open_capture_ix: Option<u32>,
+    pub close_capture_ix: Option<u32>,
+    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,
+    AroundFunction,
+    InsideClass,
+    AroundClass,
+    InsideComment,
+    AroundComment,
+}
+
+impl TextObject {
+    pub fn from_capture_name(name: &str) -> Option<TextObject> {
+        match name {
+            "function.inside" => Some(TextObject::InsideFunction),
+            "function.around" => Some(TextObject::AroundFunction),
+            "class.inside" => Some(TextObject::InsideClass),
+            "class.around" => Some(TextObject::AroundClass),
+            "comment.inside" => Some(TextObject::InsideComment),
+            "comment.around" => Some(TextObject::AroundComment),
+            _ => None,
+        }
+    }
+
+    pub fn around(&self) -> Option<Self> {
+        match self {
+            TextObject::InsideFunction => Some(TextObject::AroundFunction),
+            TextObject::InsideClass => Some(TextObject::AroundClass),
+            TextObject::InsideComment => Some(TextObject::AroundComment),
+            _ => None,
+        }
+    }
+}
+
+pub struct TextObjectConfig {
+    pub query: Query,
+    pub text_objects_by_capture_ix: Vec<(u32, TextObject)>,
+}
+
+pub struct InjectionConfig {
+    pub query: Query,
+    pub content_capture_ix: u32,
+    pub language_capture_ix: Option<u32>,
+    pub patterns: Vec<InjectionPatternConfig>,
+}
+
+pub struct RedactionConfig {
+    pub query: Query,
+    pub redaction_capture_ix: u32,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum RunnableCapture {
+    Named(SharedString),
+    Run,
+}
+
+pub struct RunnableConfig {
+    pub query: Query,
+    /// A mapping from capture index to capture kind
+    pub extra_captures: Vec<RunnableCapture>,
+}
+
+pub struct OverrideConfig {
+    pub query: Query,
+    pub values: HashMap<u32, OverrideEntry>,
+}
+
+#[derive(Debug)]
+pub struct OverrideEntry {
+    pub name: String,
+    pub range_is_inclusive: bool,
+    pub value: LanguageConfigOverride,
+}
+
+#[derive(Default, Clone)]
+pub struct InjectionPatternConfig {
+    pub language: Option<Box<str>>,
+    pub combined: bool,
+}
+
+#[derive(Debug)]
+pub struct BracketsConfig {
+    pub query: Query,
+    pub open_capture_ix: u32,
+    pub close_capture_ix: u32,
+    pub patterns: Vec<BracketsPatternConfig>,
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct BracketsPatternConfig {
+    pub newline_only: bool,
+    pub rainbow_exclude: bool,
+}
+
+pub struct DebugVariablesConfig {
+    pub query: Query,
+    pub objects_by_capture_ix: Vec<(u32, DebuggerTextObject)>,
+}
+
+pub struct ImportsConfig {
+    pub query: Query,
+    pub import_ix: u32,
+    pub name_ix: Option<u32>,
+    pub namespace_ix: Option<u32>,
+    pub source_ix: Option<u32>,
+    pub list_ix: Option<u32>,
+    pub wildcard_ix: Option<u32>,
+    pub alias_ix: Option<u32>,
+}
+
+enum Capture<'a> {
+    Required(&'static str, &'a mut u32),
+    Optional(&'static str, &'a mut Option<u32>),
+}
+
+fn populate_capture_indices(
+    query: &Query,
+    language_name: &LanguageName,
+    query_type: &str,
+    expected_prefixes: &[&str],
+    captures: &mut [Capture<'_>],
+) -> bool {
+    let mut found_required_indices = Vec::new();
+    'outer: for (ix, name) in query.capture_names().iter().enumerate() {
+        for (required_ix, capture) in captures.iter_mut().enumerate() {
+            match capture {
+                Capture::Required(capture_name, index) if capture_name == name => {
+                    **index = ix as u32;
+                    found_required_indices.push(required_ix);
+                    continue 'outer;
+                }
+                Capture::Optional(capture_name, index) if capture_name == name => {
+                    **index = Some(ix as u32);
+                    continue 'outer;
+                }
+                _ => {}
+            }
+        }
+        if !name.starts_with("_")
+            && !expected_prefixes
+                .iter()
+                .any(|&prefix| name.starts_with(prefix))
+        {
+            log::warn!(
+                "unrecognized capture name '{}' in {} {} TreeSitter query \
+                (suppress this warning by prefixing with '_')",
+                name,
+                language_name,
+                query_type
+            );
+        }
+    }
+    let mut missing_required_captures = Vec::new();
+    for (capture_ix, capture) in captures.iter().enumerate() {
+        if let Capture::Required(capture_name, _) = capture
+            && !found_required_indices.contains(&capture_ix)
+        {
+            missing_required_captures.push(*capture_name);
+        }
+    }
+    let success = missing_required_captures.is_empty();
+    if !success {
+        log::error!(
+            "missing required capture(s) in {} {} TreeSitter query: {}",
+            language_name,
+            query_type,
+            missing_required_captures.join(", ")
+        );
+    }
+    success
+}
+
+impl Grammar {
+    pub fn new(ts_language: tree_sitter::Language) -> Self {
+        Self {
+            id: GrammarId::new(),
+            highlights_config: None,
+            brackets_config: None,
+            outline_config: None,
+            text_object_config: None,
+            indents_config: None,
+            injection_config: None,
+            override_config: None,
+            redactions_config: None,
+            runnable_config: None,
+            error_query: Query::new(&ts_language, "(ERROR) @error").ok(),
+            debug_variables_config: None,
+            imports_config: None,
+            ts_language,
+            highlight_map: Default::default(),
+        }
+    }
+
+    pub fn id(&self) -> GrammarId {
+        self.id
+    }
+
+    pub fn highlight_map(&self) -> HighlightMap {
+        self.highlight_map.lock().clone()
+    }
+
+    pub fn highlight_id_for_name(&self, name: &str) -> Option<HighlightId> {
+        let capture_id = self
+            .highlights_config
+            .as_ref()?
+            .query
+            .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()
+    }
+
+    pub fn imports_config(&self) -> Option<&ImportsConfig> {
+        self.imports_config.as_ref()
+    }
+
+    /// Load all queries from `LanguageQueries` into this grammar, mutating the
+    /// associated `LanguageConfig` (the override query clears
+    /// `brackets.disabled_scopes_by_bracket_ix`).
+    pub fn with_queries(
+        mut self,
+        queries: LanguageQueries,
+        config: &mut LanguageConfig,
+    ) -> Result<Self> {
+        let name = &config.name;
+        if let Some(query) = queries.highlights {
+            self = self
+                .with_highlights_query(query.as_ref())
+                .context("Error loading highlights query")?;
+        }
+        if let Some(query) = queries.brackets {
+            self = self
+                .with_brackets_query(query.as_ref(), name)
+                .context("Error loading brackets query")?;
+        }
+        if let Some(query) = queries.indents {
+            self = self
+                .with_indents_query(query.as_ref(), name)
+                .context("Error loading indents query")?;
+        }
+        if let Some(query) = queries.outline {
+            self = self
+                .with_outline_query(query.as_ref(), name)
+                .context("Error loading outline query")?;
+        }
+        if let Some(query) = queries.injections {
+            self = self
+                .with_injection_query(query.as_ref(), name)
+                .context("Error loading injection query")?;
+        }
+        if let Some(query) = queries.overrides {
+            self = self
+                .with_override_query(
+                    query.as_ref(),
+                    name,
+                    &config.overrides,
+                    &mut config.brackets,
+                    &config.scope_opt_in_language_servers,
+                )
+                .context("Error loading override query")?;
+        }
+        if let Some(query) = queries.redactions {
+            self = self
+                .with_redaction_query(query.as_ref(), name)
+                .context("Error loading redaction query")?;
+        }
+        if let Some(query) = queries.runnables {
+            self = self
+                .with_runnable_query(query.as_ref())
+                .context("Error loading runnables query")?;
+        }
+        if let Some(query) = queries.text_objects {
+            self = self
+                .with_text_object_query(query.as_ref(), name)
+                .context("Error loading textobject query")?;
+        }
+        if let Some(query) = queries.debugger {
+            self = self
+                .with_debug_variables_query(query.as_ref(), name)
+                .context("Error loading debug variables query")?;
+        }
+        if let Some(query) = queries.imports {
+            self = self
+                .with_imports_query(query.as_ref(), name)
+                .context("Error loading imports query")?;
+        }
+        Ok(self)
+    }
+
+    pub fn with_highlights_query(mut self, source: &str) -> Result<Self> {
+        let query = Query::new(&self.ts_language, source)?;
+
+        let mut identifier_capture_indices = Vec::new();
+        for name in [
+            "variable",
+            "constant",
+            "constructor",
+            "function",
+            "function.method",
+            "function.method.call",
+            "function.special",
+            "property",
+            "type",
+            "type.interface",
+        ] {
+            identifier_capture_indices.extend(query.capture_index_for_name(name));
+        }
+
+        self.highlights_config = Some(HighlightsConfig {
+            query,
+            identifier_capture_indices,
+        });
+
+        Ok(self)
+    }
+
+    pub fn with_runnable_query(mut self, source: &str) -> Result<Self> {
+        let query = Query::new(&self.ts_language, source)?;
+        let extra_captures: Vec<_> = query
+            .capture_names()
+            .iter()
+            .map(|&name| match name {
+                "run" => RunnableCapture::Run,
+                name => RunnableCapture::Named(name.to_string().into()),
+            })
+            .collect();
+
+        self.runnable_config = Some(RunnableConfig {
+            extra_captures,
+            query,
+        });
+
+        Ok(self)
+    }
+
+    pub fn with_outline_query(
+        mut self,
+        source: &str,
+        language_name: &LanguageName,
+    ) -> Result<Self> {
+        let query = Query::new(&self.ts_language, source)?;
+        let mut item_capture_ix = 0;
+        let mut name_capture_ix = 0;
+        let mut context_capture_ix = None;
+        let mut extra_context_capture_ix = None;
+        let mut open_capture_ix = None;
+        let mut close_capture_ix = None;
+        let mut annotation_capture_ix = None;
+        if populate_capture_indices(
+            &query,
+            language_name,
+            "outline",
+            &[],
+            &mut [
+                Capture::Required("item", &mut item_capture_ix),
+                Capture::Required("name", &mut name_capture_ix),
+                Capture::Optional("context", &mut context_capture_ix),
+                Capture::Optional("context.extra", &mut extra_context_capture_ix),
+                Capture::Optional("open", &mut open_capture_ix),
+                Capture::Optional("close", &mut close_capture_ix),
+                Capture::Optional("annotation", &mut annotation_capture_ix),
+            ],
+        ) {
+            self.outline_config = Some(OutlineConfig {
+                query,
+                item_capture_ix,
+                name_capture_ix,
+                context_capture_ix,
+                extra_context_capture_ix,
+                open_capture_ix,
+                close_capture_ix,
+                annotation_capture_ix,
+            });
+        }
+        Ok(self)
+    }
+
+    pub fn with_text_object_query(
+        mut self,
+        source: &str,
+        language_name: &LanguageName,
+    ) -> Result<Self> {
+        let query = Query::new(&self.ts_language, source)?;
+
+        let mut text_objects_by_capture_ix = Vec::new();
+        for (ix, name) in query.capture_names().iter().enumerate() {
+            if let Some(text_object) = TextObject::from_capture_name(name) {
+                text_objects_by_capture_ix.push((ix as u32, text_object));
+            } else {
+                log::warn!(
+                    "unrecognized capture name '{}' in {} textobjects TreeSitter query",
+                    name,
+                    language_name,
+                );
+            }
+        }
+
+        self.text_object_config = Some(TextObjectConfig {
+            query,
+            text_objects_by_capture_ix,
+        });
+        Ok(self)
+    }
+
+    pub fn with_debug_variables_query(
+        mut self,
+        source: &str,
+        language_name: &LanguageName,
+    ) -> Result<Self> {
+        let query = Query::new(&self.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));
+            } else {
+                log::warn!(
+                    "unrecognized capture name '{}' in {} debugger TreeSitter query",
+                    name,
+                    language_name,
+                );
+            }
+        }
+
+        self.debug_variables_config = Some(DebugVariablesConfig {
+            query,
+            objects_by_capture_ix,
+        });
+        Ok(self)
+    }
+
+    pub fn with_imports_query(
+        mut self,
+        source: &str,
+        language_name: &LanguageName,
+    ) -> Result<Self> {
+        let query = Query::new(&self.ts_language, source)?;
+
+        let mut import_ix = 0;
+        let mut name_ix = None;
+        let mut namespace_ix = None;
+        let mut source_ix = None;
+        let mut list_ix = None;
+        let mut wildcard_ix = None;
+        let mut alias_ix = None;
+        if populate_capture_indices(
+            &query,
+            language_name,
+            "imports",
+            &[],
+            &mut [
+                Capture::Required("import", &mut import_ix),
+                Capture::Optional("name", &mut name_ix),
+                Capture::Optional("namespace", &mut namespace_ix),
+                Capture::Optional("source", &mut source_ix),
+                Capture::Optional("list", &mut list_ix),
+                Capture::Optional("wildcard", &mut wildcard_ix),
+                Capture::Optional("alias", &mut alias_ix),
+            ],
+        ) {
+            self.imports_config = Some(ImportsConfig {
+                query,
+                import_ix,
+                name_ix,
+                namespace_ix,
+                source_ix,
+                list_ix,
+                wildcard_ix,
+                alias_ix,
+            });
+        }
+        Ok(self)
+    }
+
+    pub fn with_brackets_query(
+        mut self,
+        source: &str,
+        language_name: &LanguageName,
+    ) -> Result<Self> {
+        let query = Query::new(&self.ts_language, source)?;
+        let mut open_capture_ix = 0;
+        let mut close_capture_ix = 0;
+        if populate_capture_indices(
+            &query,
+            language_name,
+            "brackets",
+            &[],
+            &mut [
+                Capture::Required("open", &mut open_capture_ix),
+                Capture::Required("close", &mut close_capture_ix),
+            ],
+        ) {
+            let patterns = (0..query.pattern_count())
+                .map(|ix| {
+                    let mut config = BracketsPatternConfig::default();
+                    for setting in query.property_settings(ix) {
+                        let setting_key = setting.key.as_ref();
+                        if setting_key == "newline.only" {
+                            config.newline_only = true
+                        }
+                        if setting_key == "rainbow.exclude" {
+                            config.rainbow_exclude = true
+                        }
+                    }
+                    config
+                })
+                .collect();
+            self.brackets_config = Some(BracketsConfig {
+                query,
+                open_capture_ix,
+                close_capture_ix,
+                patterns,
+            });
+        }
+        Ok(self)
+    }
+
+    pub fn with_indents_query(
+        mut self,
+        source: &str,
+        language_name: &LanguageName,
+    ) -> Result<Self> {
+        let query = Query::new(&self.ts_language, source)?;
+        let mut indent_capture_ix = 0;
+        let mut start_capture_ix = None;
+        let mut end_capture_ix = None;
+        let mut outdent_capture_ix = None;
+        if populate_capture_indices(
+            &query,
+            language_name,
+            "indents",
+            &["start."],
+            &mut [
+                Capture::Required("indent", &mut indent_capture_ix),
+                Capture::Optional("start", &mut start_capture_ix),
+                Capture::Optional("end", &mut end_capture_ix),
+                Capture::Optional("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());
+                }
+            }
+
+            self.indents_config = Some(IndentConfig {
+                query,
+                indent_capture_ix,
+                start_capture_ix,
+                end_capture_ix,
+                outdent_capture_ix,
+                suffixed_start_captures,
+            });
+        }
+        Ok(self)
+    }
+
+    pub fn with_injection_query(
+        mut self,
+        source: &str,
+        language_name: &LanguageName,
+    ) -> Result<Self> {
+        let query = Query::new(&self.ts_language, source)?;
+        let mut language_capture_ix = None;
+        let mut injection_language_capture_ix = None;
+        let mut content_capture_ix = None;
+        let mut injection_content_capture_ix = None;
+        if populate_capture_indices(
+            &query,
+            language_name,
+            "injections",
+            &[],
+            &mut [
+                Capture::Optional("language", &mut language_capture_ix),
+                Capture::Optional("injection.language", &mut injection_language_capture_ix),
+                Capture::Optional("content", &mut content_capture_ix),
+                Capture::Optional("injection.content", &mut injection_content_capture_ix),
+            ],
+        ) {
+            language_capture_ix = match (language_capture_ix, injection_language_capture_ix) {
+                (None, Some(ix)) => Some(ix),
+                (Some(_), Some(_)) => {
+                    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(_)) => {
+                    anyhow::bail!("both content and injection.content captures are present")
+                }
+                _ => content_capture_ix,
+            };
+            let patterns = (0..query.pattern_count())
+                .map(|ix| {
+                    let mut config = InjectionPatternConfig::default();
+                    for setting in query.property_settings(ix) {
+                        match setting.key.as_ref() {
+                            "language" | "injection.language" => {
+                                config.language.clone_from(&setting.value);
+                            }
+                            "combined" | "injection.combined" => {
+                                config.combined = true;
+                            }
+                            _ => {}
+                        }
+                    }
+                    config
+                })
+                .collect();
+            if let Some(content_capture_ix) = content_capture_ix {
+                self.injection_config = Some(InjectionConfig {
+                    query,
+                    language_capture_ix,
+                    content_capture_ix,
+                    patterns,
+                });
+            } else {
+                log::error!(
+                    "missing required capture in injections {} TreeSitter query: \
+                    content or injection.content",
+                    language_name,
+                );
+            }
+        }
+        Ok(self)
+    }
+
+    pub fn with_override_query(
+        mut self,
+        source: &str,
+        language_name: &LanguageName,
+        overrides: &HashMap<String, LanguageConfigOverride>,
+        brackets: &mut BracketPairConfig,
+        scope_opt_in_language_servers: &[LanguageServerName],
+    ) -> Result<Self> {
+        let query = Query::new(&self.ts_language, source)?;
+
+        let mut override_configs_by_id = HashMap::default();
+        for (ix, mut name) in query.capture_names().iter().copied().enumerate() {
+            let mut range_is_inclusive = false;
+            if name.starts_with('_') {
+                continue;
+            }
+            if let Some(prefix) = name.strip_suffix(".inclusive") {
+                name = prefix;
+                range_is_inclusive = true;
+            }
+
+            let value = overrides.get(name).cloned().unwrap_or_default();
+            for server_name in &value.opt_into_language_servers {
+                if !scope_opt_in_language_servers.contains(server_name) {
+                    util::debug_panic!(
+                        "Server {server_name:?} has been opted-in by scope {name:?} but has not been marked as an opt-in server"
+                    );
+                }
+            }
+
+            override_configs_by_id.insert(
+                ix as u32,
+                OverrideEntry {
+                    name: name.to_string(),
+                    range_is_inclusive,
+                    value,
+                },
+            );
+        }
+
+        let referenced_override_names = overrides
+            .keys()
+            .chain(brackets.disabled_scopes_by_bracket_ix.iter().flatten());
+
+        for referenced_name in referenced_override_names {
+            if !override_configs_by_id
+                .values()
+                .any(|entry| entry.name == *referenced_name)
+            {
+                anyhow::bail!(
+                    "language {:?} has overrides in config not in query: {referenced_name:?}",
+                    language_name
+                );
+            }
+        }
+
+        for entry in override_configs_by_id.values_mut() {
+            entry.value.disabled_bracket_ixs = brackets
+                .disabled_scopes_by_bracket_ix
+                .iter()
+                .enumerate()
+                .filter_map(|(ix, disabled_scope_names)| {
+                    if disabled_scope_names.contains(&entry.name) {
+                        Some(ix as u16)
+                    } else {
+                        None
+                    }
+                })
+                .collect();
+        }
+
+        brackets.disabled_scopes_by_bracket_ix.clear();
+
+        self.override_config = Some(OverrideConfig {
+            query,
+            values: override_configs_by_id,
+        });
+        Ok(self)
+    }
+
+    pub fn with_redaction_query(
+        mut self,
+        source: &str,
+        language_name: &LanguageName,
+    ) -> Result<Self> {
+        let query = Query::new(&self.ts_language, source)?;
+        let mut redaction_capture_ix = 0;
+        if populate_capture_indices(
+            &query,
+            language_name,
+            "redactions",
+            &[],
+            &mut [Capture::Required("redact", &mut redaction_capture_ix)],
+        ) {
+            self.redactions_config = Some(RedactionConfig {
+                query,
+                redaction_capture_ix,
+            });
+        }
+        Ok(self)
+    }
+}

crates/language_core/src/highlight_map.rs πŸ”—

@@ -0,0 +1,52 @@
+use std::sync::Arc;
+
+#[derive(Clone, Debug)]
+pub struct HighlightMap(Arc<[HighlightId]>);
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct HighlightId(pub u32);
+
+const DEFAULT_SYNTAX_HIGHLIGHT_ID: HighlightId = HighlightId(u32::MAX);
+
+impl HighlightMap {
+    #[inline]
+    pub fn from_ids(highlight_ids: impl IntoIterator<Item = HighlightId>) -> Self {
+        Self(highlight_ids.into_iter().collect())
+    }
+
+    #[inline]
+    pub fn get(&self, capture_id: u32) -> HighlightId {
+        self.0
+            .get(capture_id as usize)
+            .copied()
+            .unwrap_or(DEFAULT_SYNTAX_HIGHLIGHT_ID)
+    }
+}
+
+impl HighlightId {
+    pub const TABSTOP_INSERT_ID: HighlightId = HighlightId(u32::MAX - 1);
+    pub const TABSTOP_REPLACE_ID: HighlightId = HighlightId(u32::MAX - 2);
+
+    #[inline]
+    pub fn is_default(&self) -> bool {
+        *self == DEFAULT_SYNTAX_HIGHLIGHT_ID
+    }
+}
+
+impl Default for HighlightMap {
+    fn default() -> Self {
+        Self(Arc::new([]))
+    }
+}
+
+impl Default for HighlightId {
+    fn default() -> Self {
+        DEFAULT_SYNTAX_HIGHLIGHT_ID
+    }
+}
+
+impl From<HighlightId> for usize {
+    fn from(value: HighlightId) -> Self {
+        value.0 as usize
+    }
+}

crates/language_core/src/language_config.rs πŸ”—

@@ -0,0 +1,539 @@
+use crate::LanguageName;
+use collections::{HashMap, HashSet, IndexSet};
+use gpui::SharedString;
+use lsp::LanguageServerName;
+use regex::Regex;
+use schemars::{JsonSchema, SchemaGenerator, json_schema};
+use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
+use std::{num::NonZeroU32, path::Path, sync::Arc};
+use util::serde::default_true;
+
+/// Controls the soft-wrapping behavior in the editor.
+#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum SoftWrap {
+    /// Prefer a single line generally, unless an overly long line is encountered.
+    None,
+    /// Deprecated: use None instead. Left to avoid breaking existing users' configs.
+    /// Prefer a single line generally, unless an overly long line is encountered.
+    PreferLine,
+    /// Soft wrap lines that exceed the editor width.
+    EditorWidth,
+    /// Soft wrap lines at the preferred line length.
+    PreferredLineLength,
+    /// Soft wrap line at the preferred line length or the editor width (whichever is smaller).
+    Bounded,
+}
+
+/// Top-level configuration for a language, typically loaded from a `config.toml`
+/// shipped alongside the grammar.
+#[derive(Clone, Debug, Deserialize, JsonSchema)]
+pub struct LanguageConfig {
+    /// Human-readable name of the language.
+    pub name: LanguageName,
+    /// The name of this language for a Markdown code fence block
+    pub code_fence_block_name: Option<Arc<str>>,
+    /// Alternative language names that Jupyter kernels may report for this language.
+    /// Used when a kernel's `language` field differs from Zed's language name.
+    /// For example, the Nu extension would set this to `["nushell"]`.
+    #[serde(default)]
+    pub kernel_language_names: Vec<Arc<str>>,
+    // The name of the grammar in a WASM bundle (experimental).
+    pub grammar: Option<Arc<str>>,
+    /// The criteria for matching this language to a given file.
+    #[serde(flatten)]
+    pub matcher: LanguageMatcher,
+    /// List of bracket types in a language.
+    #[serde(default)]
+    pub brackets: BracketPairConfig,
+    /// 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")]
+    pub auto_indent_using_last_non_empty_line: bool,
+    // Whether indentation of pasted content should be adjusted based on the context.
+    #[serde(default)]
+    pub auto_indent_on_paste: Option<bool>,
+    /// A regex that is used to determine whether the indentation level should be
+    /// increased in the following line.
+    #[serde(default, deserialize_with = "deserialize_regex")]
+    #[schemars(schema_with = "regex_json_schema")]
+    pub increase_indent_pattern: Option<Regex>,
+    /// A regex that is used to determine whether the indentation level should be
+    /// decreased in the following line.
+    #[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.
+    #[serde(default)]
+    pub autoclose_before: String,
+    /// A placeholder used internally by Semantic Index.
+    #[serde(default)]
+    pub collapsed_placeholder: String,
+    /// A line comment string that is inserted in e.g. `toggle comments` action.
+    /// A language can have multiple flavours of line comments. All of the provided line comments are
+    /// used for comment continuations on the next line, but only the first one is used for Editor::ToggleComments.
+    #[serde(default)]
+    pub line_comments: Vec<Arc<str>>,
+    /// Delimiters and configuration for recognizing and formatting block comments.
+    #[serde(default)]
+    pub block_comment: Option<BlockCommentConfig>,
+    /// Delimiters and configuration for recognizing and formatting documentation comments.
+    #[serde(default, alias = "documentation")]
+    pub documentation_comment: Option<BlockCommentConfig>,
+    /// List markers that are inserted unchanged on newline (e.g., `- `, `* `, `+ `).
+    #[serde(default)]
+    pub unordered_list: Vec<Arc<str>>,
+    /// Configuration for ordered lists with auto-incrementing numbers on newline (e.g., `1. ` becomes `2. `).
+    #[serde(default)]
+    pub ordered_list: Vec<OrderedListConfig>,
+    /// Configuration for task lists where multiple markers map to a single continuation prefix (e.g., `- [x] ` continues as `- [ ] `).
+    #[serde(default)]
+    pub task_list: Option<TaskListConfig>,
+    /// A list of additional regex patterns that should be treated as prefixes
+    /// for creating boundaries during rewrapping, ensuring content from one
+    /// prefixed section doesn't merge with another (e.g., markdown list items).
+    /// By default, Zed treats as paragraph and comment prefixes as boundaries.
+    #[serde(default, deserialize_with = "deserialize_regex_vec")]
+    #[schemars(schema_with = "regex_vec_json_schema")]
+    pub rewrap_prefixes: Vec<Regex>,
+    /// A list of language servers that are allowed to run on subranges of a given language.
+    #[serde(default)]
+    pub scope_opt_in_language_servers: Vec<LanguageServerName>,
+    #[serde(default)]
+    pub overrides: HashMap<String, LanguageConfigOverride>,
+    /// A list of characters that Zed should treat as word characters for the
+    /// purpose of features that operate on word boundaries, like 'move to next word end'
+    /// or a whole-word search in buffer search.
+    #[serde(default)]
+    pub word_characters: HashSet<char>,
+    /// Whether to indent lines using tab characters, as opposed to multiple
+    /// spaces.
+    #[serde(default)]
+    pub hard_tabs: Option<bool>,
+    /// How many columns a tab should occupy.
+    #[serde(default)]
+    #[schemars(range(min = 1, max = 128))]
+    pub tab_size: Option<NonZeroU32>,
+    /// How to soft-wrap long lines of text.
+    #[serde(default)]
+    pub soft_wrap: Option<SoftWrap>,
+    /// When set, selections can be wrapped using prefix/suffix pairs on both sides.
+    #[serde(default)]
+    pub wrap_characters: Option<WrapCharactersConfig>,
+    /// The name of a Prettier parser that will be used for this language when no file path is available.
+    /// If there's a parser name in the language settings, that will be used instead.
+    #[serde(default)]
+    pub prettier_parser_name: Option<String>,
+    /// If true, this language is only for syntax highlighting via an injection into other
+    /// languages, but should not appear to the user as a distinct language.
+    #[serde(default)]
+    pub hidden: bool,
+    /// If configured, this language contains JSX style tags, and should support auto-closing of those tags.
+    #[serde(default)]
+    pub jsx_tag_auto_close: Option<JsxTagAutoCloseConfig>,
+    /// A list of characters that Zed should treat as word characters for completion queries.
+    #[serde(default)]
+    pub completion_query_characters: HashSet<char>,
+    /// A list of characters that Zed should treat as word characters for linked edit operations.
+    #[serde(default)]
+    pub linked_edit_characters: HashSet<char>,
+    /// A list of preferred debuggers for this language.
+    #[serde(default)]
+    pub debuggers: IndexSet<SharedString>,
+    /// A list of import namespace segments that aren't expected to appear in file paths. For
+    /// example, "super" and "crate" in Rust.
+    #[serde(default)]
+    pub ignored_import_segments: HashSet<Arc<str>>,
+    /// Regular expression that matches substrings to omit from import paths, to make the paths more
+    /// similar to how they are specified when imported. For example, "/mod\.rs$" or "/__init__\.py$".
+    #[serde(default, deserialize_with = "deserialize_regex")]
+    #[schemars(schema_with = "regex_json_schema")]
+    pub import_path_strip_regex: Option<Regex>,
+}
+
+impl LanguageConfig {
+    pub const FILE_NAME: &str = "config.toml";
+
+    pub fn load(config_path: impl AsRef<Path>) -> anyhow::Result<Self> {
+        let config = std::fs::read_to_string(config_path.as_ref())?;
+        toml::from_str(&config).map_err(Into::into)
+    }
+}
+
+impl Default for LanguageConfig {
+    fn default() -> Self {
+        Self {
+            name: LanguageName::new_static(""),
+            code_fence_block_name: None,
+            kernel_language_names: Default::default(),
+            grammar: None,
+            matcher: LanguageMatcher::default(),
+            brackets: Default::default(),
+            auto_indent_using_last_non_empty_line: auto_indent_using_last_non_empty_line_default(),
+            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(),
+            documentation_comment: Default::default(),
+            unordered_list: Default::default(),
+            ordered_list: Default::default(),
+            task_list: Default::default(),
+            rewrap_prefixes: Default::default(),
+            scope_opt_in_language_servers: Default::default(),
+            overrides: Default::default(),
+            word_characters: Default::default(),
+            collapsed_placeholder: Default::default(),
+            hard_tabs: None,
+            tab_size: None,
+            soft_wrap: None,
+            wrap_characters: None,
+            prettier_parser_name: None,
+            hidden: false,
+            jsx_tag_auto_close: None,
+            completion_query_characters: Default::default(),
+            linked_edit_characters: Default::default(),
+            debuggers: Default::default(),
+            ignored_import_segments: Default::default(),
+            import_path_strip_regex: None,
+        }
+    }
+}
+
+#[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>,
+}
+
+/// Configuration for continuing ordered lists with auto-incrementing numbers.
+#[derive(Clone, Debug, Deserialize, JsonSchema)]
+pub struct OrderedListConfig {
+    /// A regex pattern with a capture group for the number portion (e.g., `(\\d+)\\. `).
+    pub pattern: String,
+    /// A format string where `{1}` is replaced with the incremented number (e.g., `{1}. `).
+    pub format: String,
+}
+
+/// Configuration for continuing task lists on newline.
+#[derive(Clone, Debug, Deserialize, JsonSchema)]
+pub struct TaskListConfig {
+    /// The list markers to match (e.g., `- [ ] `, `- [x] `).
+    pub prefixes: Vec<Arc<str>>,
+    /// The marker to insert when continuing the list on a new line (e.g., `- [ ] `).
+    pub continuation: Arc<str>,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)]
+pub struct LanguageMatcher {
+    /// Given a list of `LanguageConfig`'s, the language of a file can be determined based on the path extension matching any of the `path_suffixes`.
+    #[serde(default)]
+    pub path_suffixes: Vec<String>,
+    /// A regex pattern that determines whether the language should be assigned to a file or not.
+    #[serde(
+        default,
+        serialize_with = "serialize_regex",
+        deserialize_with = "deserialize_regex"
+    )]
+    #[schemars(schema_with = "regex_json_schema")]
+    pub first_line_pattern: Option<Regex>,
+    /// Alternative names for this language used in vim/emacs modelines.
+    /// These are matched case-insensitively against the `mode` (emacs) or
+    /// `filetype`/`ft` (vim) specified in the modeline.
+    #[serde(default)]
+    pub modeline_aliases: Vec<String>,
+}
+
+impl Ord for LanguageMatcher {
+    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+        self.path_suffixes
+            .cmp(&other.path_suffixes)
+            .then_with(|| {
+                self.first_line_pattern
+                    .as_ref()
+                    .map(Regex::as_str)
+                    .cmp(&other.first_line_pattern.as_ref().map(Regex::as_str))
+            })
+            .then_with(|| self.modeline_aliases.cmp(&other.modeline_aliases))
+    }
+}
+
+impl PartialOrd for LanguageMatcher {
+    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+        Some(self.cmp(other))
+    }
+}
+
+impl Eq for LanguageMatcher {}
+
+impl PartialEq for LanguageMatcher {
+    fn eq(&self, other: &Self) -> bool {
+        self.path_suffixes == other.path_suffixes
+            && self.first_line_pattern.as_ref().map(Regex::as_str)
+                == other.first_line_pattern.as_ref().map(Regex::as_str)
+            && self.modeline_aliases == other.modeline_aliases
+    }
+}
+
+/// The configuration for JSX tag auto-closing.
+#[derive(Clone, Deserialize, JsonSchema, Debug)]
+pub struct JsxTagAutoCloseConfig {
+    /// The name of the node for a opening tag
+    pub open_tag_node_name: String,
+    /// The name of the node for an closing tag
+    pub close_tag_node_name: String,
+    /// The name of the node for a complete element with children for open and close tags
+    pub jsx_element_node_name: String,
+    /// The name of the node found within both opening and closing
+    /// tags that describes the tag name
+    pub tag_name_node_name: String,
+    /// Alternate Node names for tag names.
+    /// Specifically needed as TSX represents the name in `<Foo.Bar>`
+    /// as `member_expression` rather than `identifier` as usual
+    #[serde(default)]
+    pub tag_name_node_name_alternates: Vec<String>,
+    /// Some grammars are smart enough to detect a closing tag
+    /// that is not valid i.e. doesn't match it's corresponding
+    /// opening tag or does not have a corresponding opening tag
+    /// This should be set to the name of the node for invalid
+    /// closing tags if the grammar contains such a node, otherwise
+    /// detecting already closed tags will not work properly
+    #[serde(default)]
+    pub erroneous_close_tag_node_name: Option<String>,
+    /// See above for erroneous_close_tag_node_name for details
+    /// This should be set if the node used for the tag name
+    /// within erroneous closing tags is different from the
+    /// normal tag name node name
+    #[serde(default)]
+    pub erroneous_close_tag_name_node_name: Option<String>,
+}
+
+/// The configuration for block comments for this language.
+#[derive(Clone, Debug, JsonSchema, PartialEq)]
+pub struct BlockCommentConfig {
+    /// A start tag of block comment.
+    pub start: Arc<str>,
+    /// A end tag of block comment.
+    pub end: Arc<str>,
+    /// A character to add as a prefix when a new line is added to a block comment.
+    pub prefix: Arc<str>,
+    /// A indent to add for prefix and end line upon new line.
+    #[schemars(range(min = 1, max = 128))]
+    pub tab_size: u32,
+}
+
+impl<'de> Deserialize<'de> for BlockCommentConfig {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        #[derive(Deserialize)]
+        #[serde(untagged)]
+        enum BlockCommentConfigHelper {
+            New {
+                start: Arc<str>,
+                end: Arc<str>,
+                prefix: Arc<str>,
+                tab_size: u32,
+            },
+            Old([Arc<str>; 2]),
+        }
+
+        match BlockCommentConfigHelper::deserialize(deserializer)? {
+            BlockCommentConfigHelper::New {
+                start,
+                end,
+                prefix,
+                tab_size,
+            } => Ok(BlockCommentConfig {
+                start,
+                end,
+                prefix,
+                tab_size,
+            }),
+            BlockCommentConfigHelper::Old([start, end]) => Ok(BlockCommentConfig {
+                start,
+                end,
+                prefix: "".into(),
+                tab_size: 0,
+            }),
+        }
+    }
+}
+
+#[derive(Clone, Deserialize, Default, Debug, JsonSchema)]
+pub struct LanguageConfigOverride {
+    #[serde(default)]
+    pub line_comments: Override<Vec<Arc<str>>>,
+    #[serde(default)]
+    pub block_comment: Override<BlockCommentConfig>,
+    #[serde(skip)]
+    pub disabled_bracket_ixs: Vec<u16>,
+    #[serde(default)]
+    pub word_characters: Override<HashSet<char>>,
+    #[serde(default)]
+    pub completion_query_characters: Override<HashSet<char>>,
+    #[serde(default)]
+    pub linked_edit_characters: Override<HashSet<char>>,
+    #[serde(default)]
+    pub opt_into_language_servers: Vec<LanguageServerName>,
+    #[serde(default)]
+    pub prefer_label_for_snippet: Option<bool>,
+}
+
+#[derive(Clone, Deserialize, Debug, Serialize, JsonSchema)]
+#[serde(untagged)]
+pub enum Override<T> {
+    Remove { remove: bool },
+    Set(T),
+}
+
+impl<T> Default for Override<T> {
+    fn default() -> Self {
+        Override::Remove { remove: false }
+    }
+}
+
+impl<T> Override<T> {
+    pub fn as_option<'a>(this: Option<&'a Self>, original: Option<&'a T>) -> Option<&'a T> {
+        match this {
+            Some(Self::Set(value)) => Some(value),
+            Some(Self::Remove { remove: true }) => None,
+            Some(Self::Remove { remove: false }) | None => original,
+        }
+    }
+}
+
+/// Configuration of handling bracket pairs for a given language.
+///
+/// This struct includes settings for defining which pairs of characters are considered brackets and
+/// also specifies any language-specific scopes where these pairs should be ignored for bracket matching purposes.
+#[derive(Clone, Debug, Default, JsonSchema)]
+#[schemars(with = "Vec::<BracketPairContent>")]
+pub struct BracketPairConfig {
+    /// A list of character pairs that should be treated as brackets in the context of a given language.
+    pub pairs: Vec<BracketPair>,
+    /// A list of tree-sitter scopes for which a given bracket should not be active.
+    /// N-th entry in `[Self::disabled_scopes_by_bracket_ix]` contains a list of disabled scopes for an n-th entry in `[Self::pairs]`
+    pub disabled_scopes_by_bracket_ix: Vec<Vec<String>>,
+}
+
+impl BracketPairConfig {
+    pub fn is_closing_brace(&self, c: char) -> bool {
+        self.pairs.iter().any(|pair| pair.end.starts_with(c))
+    }
+}
+
+#[derive(Deserialize, JsonSchema)]
+pub struct BracketPairContent {
+    #[serde(flatten)]
+    pub bracket_pair: BracketPair,
+    #[serde(default)]
+    pub not_in: Vec<String>,
+}
+
+impl<'de> Deserialize<'de> for BracketPairConfig {
+    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        let result = Vec::<BracketPairContent>::deserialize(deserializer)?;
+        let (brackets, disabled_scopes_by_bracket_ix) = result
+            .into_iter()
+            .map(|entry| (entry.bracket_pair, entry.not_in))
+            .unzip();
+
+        Ok(BracketPairConfig {
+            pairs: brackets,
+            disabled_scopes_by_bracket_ix,
+        })
+    }
+}
+
+/// Describes a single bracket pair and how an editor should react to e.g. inserting
+/// an opening bracket or to a newline character insertion in between `start` and `end` characters.
+#[derive(Clone, Debug, Default, Deserialize, PartialEq, JsonSchema)]
+pub struct BracketPair {
+    /// Starting substring for a bracket.
+    pub start: String,
+    /// Ending substring for a bracket.
+    pub end: String,
+    /// True if `end` should be automatically inserted right after `start` characters.
+    pub close: bool,
+    /// True if selected text should be surrounded by `start` and `end` characters.
+    #[serde(default = "default_true")]
+    pub surround: bool,
+    /// True if an extra newline should be inserted while the cursor is in the middle
+    /// of that bracket pair.
+    pub newline: bool,
+}
+
+#[derive(Clone, Debug, Deserialize, JsonSchema)]
+pub struct WrapCharactersConfig {
+    /// Opening token split into a prefix and suffix. The first caret goes
+    /// after the prefix (i.e., between prefix and suffix).
+    pub start_prefix: String,
+    pub start_suffix: String,
+    /// Closing token split into a prefix and suffix. The second caret goes
+    /// after the prefix (i.e., between prefix and suffix).
+    pub end_prefix: String,
+    pub end_suffix: String,
+}
+
+pub fn auto_indent_using_last_non_empty_line_default() -> bool {
+    true
+}
+
+pub fn deserialize_regex<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Regex>, D::Error> {
+    let source = Option::<String>::deserialize(d)?;
+    if let Some(source) = source {
+        Ok(Some(regex::Regex::new(&source).map_err(de::Error::custom)?))
+    } else {
+        Ok(None)
+    }
+}
+
+pub fn regex_json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
+    json_schema!({
+        "type": "string"
+    })
+}
+
+pub fn serialize_regex<S>(regex: &Option<Regex>, serializer: S) -> Result<S::Ok, S::Error>
+where
+    S: Serializer,
+{
+    match regex {
+        Some(regex) => serializer.serialize_str(regex.as_str()),
+        None => serializer.serialize_none(),
+    }
+}
+
+pub fn deserialize_regex_vec<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<Regex>, D::Error> {
+    let sources = Vec::<String>::deserialize(d)?;
+    sources
+        .into_iter()
+        .map(|source| regex::Regex::new(&source))
+        .collect::<Result<_, _>>()
+        .map_err(de::Error::custom)
+}
+
+pub fn regex_vec_json_schema(_: &mut SchemaGenerator) -> schemars::Schema {
+    json_schema!({
+        "type": "array",
+        "items": { "type": "string" }
+    })
+}

crates/language_core/src/language_core.rs πŸ”—

@@ -0,0 +1,39 @@
+// language_core: tree-sitter grammar infrastructure, LSP adapter traits,
+// language configuration, and highlight mapping.
+
+pub mod diagnostic;
+pub mod grammar;
+pub mod highlight_map;
+pub mod language_config;
+
+pub use diagnostic::{Diagnostic, DiagnosticSourceKind};
+pub use grammar::{
+    BracketsConfig, BracketsPatternConfig, DebugVariablesConfig, DebuggerTextObject, Grammar,
+    GrammarId, HighlightsConfig, ImportsConfig, IndentConfig, InjectionConfig,
+    InjectionPatternConfig, NEXT_GRAMMAR_ID, OutlineConfig, OverrideConfig, OverrideEntry,
+    RedactionConfig, RunnableCapture, RunnableConfig, TextObject, TextObjectConfig,
+};
+pub use highlight_map::{HighlightId, HighlightMap};
+pub use language_config::{
+    BlockCommentConfig, BracketPair, BracketPairConfig, BracketPairContent, DecreaseIndentConfig,
+    JsxTagAutoCloseConfig, LanguageConfig, LanguageConfigOverride, LanguageMatcher,
+    OrderedListConfig, Override, SoftWrap, TaskListConfig, WrapCharactersConfig,
+    auto_indent_using_last_non_empty_line_default, deserialize_regex, deserialize_regex_vec,
+    regex_json_schema, regex_vec_json_schema, serialize_regex,
+};
+
+pub mod code_label;
+pub mod language_name;
+pub mod lsp_adapter;
+pub mod manifest;
+pub mod queries;
+pub mod toolchain;
+
+pub use code_label::{CodeLabel, CodeLabelBuilder, Symbol};
+pub use language_name::{LanguageId, LanguageName};
+pub use lsp_adapter::{
+    BinaryStatus, LanguageServerStatusUpdate, PromptResponseContext, ServerHealth, ToLspPosition,
+};
+pub use manifest::ManifestName;
+pub use queries::{LanguageQueries, QUERY_FILENAME_PREFIXES};
+pub use toolchain::{Toolchain, ToolchainList, ToolchainMetadata, ToolchainScope};

crates/language_core/src/language_name.rs πŸ”—

@@ -0,0 +1,109 @@
+use gpui::SharedString;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::{
+    borrow::Borrow,
+    sync::atomic::{AtomicUsize, Ordering::SeqCst},
+};
+
+static NEXT_LANGUAGE_ID: AtomicUsize = AtomicUsize::new(0);
+
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
+pub struct LanguageId(usize);
+
+impl LanguageId {
+    pub fn new() -> Self {
+        Self(NEXT_LANGUAGE_ID.fetch_add(1, SeqCst))
+    }
+}
+
+impl Default for LanguageId {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+#[derive(
+    Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
+)]
+pub struct LanguageName(pub SharedString);
+
+impl LanguageName {
+    pub fn new(s: &str) -> Self {
+        Self(SharedString::new(s))
+    }
+
+    pub fn new_static(s: &'static str) -> Self {
+        Self(SharedString::new_static(s))
+    }
+
+    pub fn from_proto(s: String) -> Self {
+        Self(SharedString::from(s))
+    }
+
+    pub fn to_proto(&self) -> String {
+        self.0.to_string()
+    }
+
+    pub fn lsp_id(&self) -> String {
+        match self.0.as_ref() {
+            "Plain Text" => "plaintext".to_string(),
+            language_name => language_name.to_lowercase(),
+        }
+    }
+}
+
+impl From<LanguageName> for SharedString {
+    fn from(value: LanguageName) -> Self {
+        value.0
+    }
+}
+
+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()
+    }
+}
+
+impl Borrow<str> for LanguageName {
+    fn borrow(&self) -> &str {
+        self.0.as_ref()
+    }
+}
+
+impl PartialEq<str> for LanguageName {
+    fn eq(&self, other: &str) -> bool {
+        self.0.as_ref() == other
+    }
+}
+
+impl PartialEq<&str> for LanguageName {
+    fn eq(&self, other: &&str) -> bool {
+        self.0.as_ref() == *other
+    }
+}
+
+impl std::fmt::Display for LanguageName {
+    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+        write!(f, "{}", self.0)
+    }
+}
+
+impl From<&'static str> for LanguageName {
+    fn from(str: &'static str) -> Self {
+        Self(SharedString::new_static(str))
+    }
+}
+
+impl From<LanguageName> for String {
+    fn from(value: LanguageName) -> Self {
+        let value: &str = &value.0;
+        Self::from(value)
+    }
+}

crates/language_core/src/lsp_adapter.rs πŸ”—

@@ -0,0 +1,44 @@
+use gpui::SharedString;
+use serde::{Deserialize, Serialize};
+
+/// Converts a value into an LSP position.
+pub trait ToLspPosition {
+    /// Converts the value into an LSP position.
+    fn to_lsp_position(self) -> lsp::Position;
+}
+
+/// Context provided to LSP adapters when a user responds to a ShowMessageRequest prompt.
+/// This allows adapters to intercept preference selections (like "Always" or "Never")
+/// and potentially persist them to Zed's settings.
+#[derive(Debug, Clone)]
+pub struct PromptResponseContext {
+    /// The original message shown to the user
+    pub message: String,
+    /// The action (button) the user selected
+    pub selected_action: lsp::MessageActionItem,
+}
+
+#[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 },
+}

crates/language_core/src/manifest.rs πŸ”—

@@ -0,0 +1,36 @@
+use std::borrow::Borrow;
+
+use gpui::SharedString;
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+pub struct ManifestName(SharedString);
+
+impl Borrow<SharedString> for ManifestName {
+    fn borrow(&self) -> &SharedString {
+        &self.0
+    }
+}
+
+impl Borrow<str> for ManifestName {
+    fn borrow(&self) -> &str {
+        &self.0
+    }
+}
+
+impl From<SharedString> for ManifestName {
+    fn from(value: SharedString) -> Self {
+        Self(value)
+    }
+}
+
+impl From<ManifestName> for SharedString {
+    fn from(value: ManifestName) -> Self {
+        value.0
+    }
+}
+
+impl AsRef<SharedString> for ManifestName {
+    fn as_ref(&self) -> &SharedString {
+        &self.0
+    }
+}

crates/language_core/src/queries.rs πŸ”—

@@ -0,0 +1,33 @@
+use std::borrow::Cow;
+
+pub type QueryFieldAccessor = fn(&mut LanguageQueries) -> &mut Option<Cow<'static, str>>;
+
+pub const QUERY_FILENAME_PREFIXES: &[(&str, QueryFieldAccessor)] = &[
+    ("highlights", |q| &mut q.highlights),
+    ("brackets", |q| &mut q.brackets),
+    ("outline", |q| &mut q.outline),
+    ("indents", |q| &mut q.indents),
+    ("injections", |q| &mut q.injections),
+    ("overrides", |q| &mut q.overrides),
+    ("redactions", |q| &mut q.redactions),
+    ("runnables", |q| &mut q.runnables),
+    ("debugger", |q| &mut q.debugger),
+    ("textobjects", |q| &mut q.text_objects),
+    ("imports", |q| &mut q.imports),
+];
+
+/// Tree-sitter language queries for a given language.
+#[derive(Debug, Default)]
+pub struct LanguageQueries {
+    pub highlights: Option<Cow<'static, str>>,
+    pub brackets: Option<Cow<'static, str>>,
+    pub indents: Option<Cow<'static, str>>,
+    pub outline: Option<Cow<'static, str>>,
+    pub injections: Option<Cow<'static, str>>,
+    pub overrides: Option<Cow<'static, str>>,
+    pub redactions: Option<Cow<'static, str>>,
+    pub runnables: Option<Cow<'static, str>>,
+    pub text_objects: Option<Cow<'static, str>>,
+    pub debugger: Option<Cow<'static, str>>,
+    pub imports: Option<Cow<'static, str>>,
+}

crates/language_core/src/toolchain.rs πŸ”—

@@ -0,0 +1,124 @@
+//! Provides core data types for language toolchains.
+//!
+//! A language can have associated toolchains,
+//! which is a set of tools used to interact with the projects written in said language.
+//! For example, a Python project can have an associated virtual environment; a Rust project can have a toolchain override.
+
+use std::{path::Path, sync::Arc};
+
+use gpui::SharedString;
+use util::rel_path::RelPath;
+
+use crate::{LanguageName, ManifestName};
+
+/// Represents a single toolchain.
+#[derive(Clone, Eq, Debug)]
+pub struct Toolchain {
+    /// User-facing label
+    pub name: SharedString,
+    /// Absolute path
+    pub path: SharedString,
+    pub language_name: LanguageName,
+    /// Full toolchain data (including language-specific details)
+    pub as_json: serde_json::Value,
+}
+
+impl std::hash::Hash for Toolchain {
+    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+        let Self {
+            name,
+            path,
+            language_name,
+            as_json: _,
+        } = self;
+        name.hash(state);
+        path.hash(state);
+        language_name.hash(state);
+    }
+}
+
+impl PartialEq for Toolchain {
+    fn eq(&self, other: &Self) -> bool {
+        let Self {
+            name,
+            path,
+            language_name,
+            as_json: _,
+        } = self;
+        // Do not use as_json for comparisons; it shouldn't impact equality, as it's not user-surfaced.
+        // Thus, there could be multiple entries that look the same in the UI.
+        (name, path, language_name).eq(&(&other.name, &other.path, &other.language_name))
+    }
+}
+
+/// Declares a scope of a toolchain added by user.
+///
+/// When the user adds a toolchain, we give them an option to see that toolchain in:
+/// - All of their projects
+/// - A project they're currently in.
+/// - Only in the subproject they're currently in.
+#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
+pub enum ToolchainScope {
+    Subproject(Arc<Path>, Arc<RelPath>),
+    Project,
+    /// Available in all projects on this box. It wouldn't make sense to show suggestions across machines.
+    Global,
+}
+
+impl ToolchainScope {
+    pub fn label(&self) -> &'static str {
+        match self {
+            ToolchainScope::Subproject(_, _) => "Subproject",
+            ToolchainScope::Project => "Project",
+            ToolchainScope::Global => "Global",
+        }
+    }
+
+    pub fn description(&self) -> &'static str {
+        match self {
+            ToolchainScope::Subproject(_, _) => {
+                "Available only in the subproject you're currently in."
+            }
+            ToolchainScope::Project => "Available in all locations in your current project.",
+            ToolchainScope::Global => "Available in all of your projects on this machine.",
+        }
+    }
+}
+
+#[derive(Clone, PartialEq, Eq, Hash)]
+pub struct ToolchainMetadata {
+    /// Returns a term which we should use in UI to refer to toolchains produced by a given `ToolchainLister`.
+    pub term: SharedString,
+    /// A user-facing placeholder describing the semantic meaning of a path to a new toolchain.
+    pub new_toolchain_placeholder: SharedString,
+    /// The name of the manifest file for this toolchain.
+    pub manifest_name: ManifestName,
+}
+
+type DefaultIndex = usize;
+#[derive(Default, Clone, Debug)]
+pub struct ToolchainList {
+    pub toolchains: Vec<Toolchain>,
+    pub default: Option<DefaultIndex>,
+    pub groups: Box<[(usize, SharedString)]>,
+}
+
+impl ToolchainList {
+    pub fn toolchains(&self) -> &[Toolchain] {
+        &self.toolchains
+    }
+    pub fn default_toolchain(&self) -> Option<Toolchain> {
+        self.default.and_then(|ix| self.toolchains.get(ix)).cloned()
+    }
+    pub fn group_for_index(&self, index: usize) -> Option<(usize, SharedString)> {
+        if index >= self.toolchains.len() {
+            return None;
+        }
+        let first_equal_or_greater = self
+            .groups
+            .partition_point(|(group_lower_bound, _)| group_lower_bound <= &index);
+        self.groups
+            .get(first_equal_or_greater.checked_sub(1)?)
+            .cloned()
+    }
+}

crates/language_extension/src/extension_lsp_adapter.rs πŸ”—

@@ -547,15 +547,16 @@ fn build_code_label(
                 text.push_str(code_span);
             }
             extension::CodeLabelSpan::Literal(span) => {
-                let highlight_id = language
+                if let Some(highlight_id) = language
                     .grammar()
                     .zip(span.highlight_name.as_ref())
                     .and_then(|(grammar, highlight_name)| {
                         grammar.highlight_id_for_name(highlight_name)
                     })
-                    .unwrap_or_default();
-                let ix = text.len();
-                runs.push((ix..ix + span.text.len(), highlight_id));
+                {
+                    let ix = text.len();
+                    runs.push((ix..ix + span.text.len(), highlight_id));
+                }
                 text.push_str(&span.text);
             }
         }

crates/language_model/src/tool_schema.rs πŸ”—

@@ -17,7 +17,12 @@ pub enum LanguageModelToolSchemaFormat {
 
 pub fn root_schema_for<T: JsonSchema>(format: LanguageModelToolSchemaFormat) -> Schema {
     let mut generator = match format {
-        LanguageModelToolSchemaFormat::JsonSchema => SchemaSettings::draft07().into_generator(),
+        LanguageModelToolSchemaFormat::JsonSchema => SchemaSettings::draft07()
+            .with(|settings| {
+                settings.meta_schema = None;
+                settings.inline_subschemas = true;
+            })
+            .into_generator(),
         LanguageModelToolSchemaFormat::JsonSchemaSubset => SchemaSettings::openapi3()
             .with(|settings| {
                 settings.meta_schema = None;

crates/language_onboarding/Cargo.toml πŸ”—

@@ -21,9 +21,3 @@ gpui.workspace = true
 project.workspace = true
 ui.workspace = true
 workspace.workspace = true
-
-# Uncomment other workspace dependencies as needed
-# assistant.workspace = true
-# client.workspace = true
-# project.workspace = true
-# settings.workspace = true

crates/language_tools/src/highlights_tree_view.rs πŸ”—

@@ -9,6 +9,7 @@ use gpui::{
     Task, UniformListScrollHandle, WeakEntity, Window, actions, div, rems, uniform_list,
 };
 use language::ToOffset;
+
 use menu::{SelectNext, SelectPrevious};
 use std::{mem, ops::Range};
 use theme::ActiveTheme;
@@ -375,7 +376,9 @@ impl HighlightsTreeView {
                                             rule.style
                                                 .iter()
                                                 .find(|style_name| {
-                                                    semantic_theme.get_opt(style_name).is_some()
+                                                    semantic_theme
+                                                        .style_for_name(style_name)
+                                                        .is_some()
                                                 })
                                                 .map(|style_name| {
                                                     SharedString::from(style_name.clone())
@@ -417,12 +420,12 @@ impl HighlightsTreeView {
 
             for capture in captures {
                 let highlight_id = highlight_maps[capture.grammar_index].get(capture.index);
-                let Some(style) = highlight_id.style(&syntax_theme) else {
+                let Some(style) = syntax_theme.get(highlight_id).cloned() else {
                     continue;
                 };
 
-                let theme_key = highlight_id
-                    .name(&syntax_theme)
+                let theme_key = syntax_theme
+                    .get_capture_name(highlight_id)
                     .map(|theme_key| SharedString::from(theme_key.to_string()));
 
                 let capture_name = grammars[capture.grammar_index]

crates/languages/Cargo.toml πŸ”—

@@ -13,24 +13,9 @@ test-support = [
     "load-grammars"
 ]
 load-grammars = [
+    "grammars/load-grammars",
     "tree-sitter",
-    "tree-sitter-bash",
-    "tree-sitter-c",
-    "tree-sitter-cpp",
-    "tree-sitter-css",
-    "tree-sitter-diff",
     "tree-sitter-gitcommit",
-    "tree-sitter-go",
-    "tree-sitter-go-mod",
-    "tree-sitter-gowork",
-    "tree-sitter-jsdoc",
-    "tree-sitter-json",
-    "tree-sitter-md",
-    "tree-sitter-python",
-    "tree-sitter-regex",
-    "tree-sitter-rust",
-    "tree-sitter-typescript",
-    "tree-sitter-yaml",
 ]
 
 [dependencies]
@@ -44,6 +29,7 @@ collections.workspace = true
 futures.workspace = true
 globset.workspace = true
 gpui.workspace = true
+grammars.workspace = true
 http_client.workspace = true
 itertools.workspace = true
 json_schema_store.workspace = true
@@ -62,7 +48,6 @@ pet.workspace = true
 project.workspace = true
 regex.workspace = true
 rope.workspace = true
-rust-embed.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 serde_json_lenient.workspace = true
@@ -74,29 +59,13 @@ snippet.workspace = true
 task.workspace = true
 terminal.workspace = true
 theme.workspace = true
-toml.workspace = true
 tree-sitter = { workspace = true, optional = true }
-tree-sitter-bash = { workspace = true, optional = true }
-tree-sitter-c = { workspace = true, optional = true }
-tree-sitter-cpp = { workspace = true, optional = true }
-tree-sitter-css = { workspace = true, optional = true }
-tree-sitter-diff = { workspace = true, optional = true }
 tree-sitter-gitcommit = { workspace = true, optional = true }
-tree-sitter-go = { workspace = true, optional = true }
-tree-sitter-go-mod = { workspace = true, optional = true }
-tree-sitter-gowork = { workspace = true, optional = true }
-tree-sitter-jsdoc = { workspace = true, optional = true }
-tree-sitter-json = { workspace = true, optional = true }
-tree-sitter-md = { workspace = true, optional = true }
-tree-sitter-python = { workspace = true, optional = true }
-tree-sitter-regex = { workspace = true, optional = true }
-tree-sitter-rust = { workspace = true, optional = true }
-tree-sitter-typescript = { workspace = true, optional = true }
-tree-sitter-yaml = { workspace = true, optional = true }
 url.workspace = true
 util.workspace = true
 
 [dev-dependencies]
+fs = { workspace = true, features = ["test-support"] }
 pretty_assertions.workspace = true
 theme = { workspace = true, features = ["test-support"] }
 tree-sitter-bash.workspace = true
@@ -105,6 +74,7 @@ tree-sitter-cpp.workspace = true
 tree-sitter-css.workspace = true
 tree-sitter-go.workspace = true
 tree-sitter-python.workspace = true
+tree-sitter-rust.workspace = true
 tree-sitter-typescript.workspace = true
 tree-sitter.workspace = true
 unindent.workspace = true

crates/languages/src/c.rs πŸ”—

@@ -368,7 +368,7 @@ impl super::LspAdapter for CLspAdapter {
         Ok(original)
     }
 
-    fn retain_old_diagnostic(&self, previous_diagnostic: &Diagnostic, _: &App) -> bool {
+    fn retain_old_diagnostic(&self, previous_diagnostic: &Diagnostic) -> bool {
         clangd_ext::is_inactive_region(previous_diagnostic)
     }
 

crates/languages/src/cpp.rs πŸ”—

@@ -1,9 +1,7 @@
 use settings::SemanticTokenRules;
 
-use crate::LanguageDir;
-
 pub(crate) fn semantic_token_rules() -> SemanticTokenRules {
-    let content = LanguageDir::get("cpp/semantic_token_rules.json")
+    let content = grammars::get_file("cpp/semantic_token_rules.json")
         .expect("missing cpp/semantic_token_rules.json");
     let json = std::str::from_utf8(&content.data).expect("invalid utf-8 in semantic_token_rules");
     settings::parse_json_with_comments::<SemanticTokenRules>(json)

crates/languages/src/go.rs πŸ”—

@@ -31,10 +31,8 @@ use std::{
 use task::{TaskTemplate, TaskTemplates, TaskVariables, VariableName};
 use util::{ResultExt, fs::remove_matching, maybe, merge_json_value_into};
 
-use crate::LanguageDir;
-
 pub(crate) fn semantic_token_rules() -> SemanticTokenRules {
-    let content = LanguageDir::get("go/semantic_token_rules.json")
+    let content = grammars::get_file("go/semantic_token_rules.json")
         .expect("missing go/semantic_token_rules.json");
     let json = std::str::from_utf8(&content.data).expect("invalid utf-8 in semantic_token_rules");
     settings::parse_json_with_comments::<SemanticTokenRules>(json)

crates/languages/src/lib.rs πŸ”—

@@ -1,14 +1,12 @@
-use anyhow::Context as _;
 use gpui::{App, SharedString, UpdateGlobal};
 use node_runtime::NodeRuntime;
 use project::Fs;
 use python::PyprojectTomlManifestProvider;
 use rust::CargoManifestProvider;
-use rust_embed::RustEmbed;
 use settings::{SemanticTokenRules, SettingsStore};
 use smol::stream::StreamExt;
-use std::{str, sync::Arc};
-use util::{ResultExt, asset_str};
+use std::sync::Arc;
+use util::ResultExt;
 
 pub use language::*;
 
@@ -35,11 +33,6 @@ mod yaml;
 
 pub(crate) use package_json::{PackageJson, PackageJsonData};
 
-#[derive(RustEmbed)]
-#[folder = "src/"]
-#[exclude = "*.rs"]
-struct LanguageDir;
-
 /// A shared grammar for plain text, exposed for reuse by downstream crates.
 #[cfg(feature = "tree-sitter-gitcommit")]
 pub static LANGUAGE_GIT_COMMIT: std::sync::LazyLock<Arc<Language>> =
@@ -47,7 +40,7 @@ pub static LANGUAGE_GIT_COMMIT: std::sync::LazyLock<Arc<Language>> =
         Arc::new(Language::new(
             LanguageConfig {
                 name: "Git Commit".into(),
-                soft_wrap: Some(language::language_settings::SoftWrap::EditorWidth),
+                soft_wrap: Some(language::SoftWrap::EditorWidth),
                 matcher: LanguageMatcher {
                     path_suffixes: vec!["COMMIT_EDITMSG".to_owned()],
                     first_line_pattern: None,
@@ -62,28 +55,7 @@ pub static LANGUAGE_GIT_COMMIT: std::sync::LazyLock<Arc<Language>> =
 
 pub fn init(languages: Arc<LanguageRegistry>, fs: Arc<dyn Fs>, node: NodeRuntime, cx: &mut App) {
     #[cfg(feature = "load-grammars")]
-    languages.register_native_grammars([
-        ("bash", tree_sitter_bash::LANGUAGE),
-        ("c", tree_sitter_c::LANGUAGE),
-        ("cpp", tree_sitter_cpp::LANGUAGE),
-        ("css", tree_sitter_css::LANGUAGE),
-        ("diff", tree_sitter_diff::LANGUAGE),
-        ("go", tree_sitter_go::LANGUAGE),
-        ("gomod", tree_sitter_go_mod::LANGUAGE),
-        ("gowork", tree_sitter_gowork::LANGUAGE),
-        ("jsdoc", tree_sitter_jsdoc::LANGUAGE),
-        ("json", tree_sitter_json::LANGUAGE),
-        ("jsonc", tree_sitter_json::LANGUAGE),
-        ("markdown", tree_sitter_md::LANGUAGE),
-        ("markdown-inline", tree_sitter_md::INLINE_LANGUAGE),
-        ("python", tree_sitter_python::LANGUAGE),
-        ("regex", tree_sitter_regex::LANGUAGE),
-        ("rust", tree_sitter_rust::LANGUAGE),
-        ("tsx", tree_sitter_typescript::LANGUAGE_TSX),
-        ("typescript", tree_sitter_typescript::LANGUAGE_TYPESCRIPT),
-        ("yaml", tree_sitter_yaml::LANGUAGE),
-        ("gitcommit", tree_sitter_gitcommit::LANGUAGE),
-    ]);
+    languages.register_native_grammars(grammars::native_grammars());
 
     let c_lsp_adapter = Arc::new(c::CLspAdapter);
     let css_lsp_adapter = Arc::new(css::CssLspAdapter::new(node.clone()));
@@ -99,7 +71,7 @@ pub fn init(languages: Arc<LanguageRegistry>, fs: Arc<dyn Fs>, node: NodeRuntime
     let python_lsp_adapter = Arc::new(python::PyrightLspAdapter::new(node.clone()));
     let basedpyright_lsp_adapter = Arc::new(BasedPyrightLspAdapter::new(node.clone()));
     let ruff_lsp_adapter = Arc::new(RuffLspAdapter::new(fs.clone()));
-    let python_toolchain_provider = Arc::new(python::PythonToolchainProvider);
+    let python_toolchain_provider = Arc::new(python::PythonToolchainProvider::new(fs.clone()));
     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()));
@@ -402,56 +374,17 @@ fn register_language(
 #[cfg(any(test, feature = "test-support"))]
 pub fn language(name: &str, grammar: tree_sitter::Language) -> Arc<Language> {
     Arc::new(
-        Language::new(load_config(name), Some(grammar))
-            .with_queries(load_queries(name))
+        Language::new(grammars::load_config(name), Some(grammar))
+            .with_queries(grammars::load_queries(name))
             .unwrap(),
     )
 }
 
 fn load_config(name: &str) -> LanguageConfig {
-    let config_toml = String::from_utf8(
-        LanguageDir::get(&format!("{}/config.toml", name))
-            .unwrap_or_else(|| panic!("missing config for language {:?}", name))
-            .data
-            .to_vec(),
-    )
-    .unwrap();
-
-    #[allow(unused_mut)]
-    let mut config: LanguageConfig = ::toml::from_str(&config_toml)
-        .with_context(|| format!("failed to load config.toml for language {name:?}"))
-        .unwrap();
-
-    #[cfg(not(any(feature = "load-grammars", test)))]
-    {
-        config = LanguageConfig {
-            name: config.name,
-            matcher: config.matcher,
-            jsx_tag_auto_close: config.jsx_tag_auto_close,
-            ..Default::default()
-        }
-    }
-
-    config
+    let grammars_loaded = cfg!(any(feature = "load-grammars", test));
+    grammars::load_config_for_feature(name, grammars_loaded)
 }
 
 fn load_queries(name: &str) -> LanguageQueries {
-    let mut result = LanguageQueries::default();
-    for path in LanguageDir::iter() {
-        if let Some(remainder) = path.strip_prefix(name).and_then(|p| p.strip_prefix('/')) {
-            if !remainder.ends_with(".scm") {
-                continue;
-            }
-            for (name, query) in QUERY_FILENAME_PREFIXES {
-                if remainder.starts_with(name) {
-                    let contents = asset_str::<LanguageDir>(path.as_ref());
-                    match query(&mut result) {
-                        None => *query(&mut result) = Some(contents),
-                        Some(r) => r.to_mut().push_str(contents.as_ref()),
-                    }
-                }
-            }
-        }
-    }
-    result
+    grammars::load_queries(name)
 }

crates/languages/src/python.rs πŸ”—

@@ -39,7 +39,6 @@ use util::fs::{make_file_executable, remove_matching};
 use util::paths::PathStyle;
 use util::rel_path::RelPath;
 
-use crate::LanguageDir;
 use http_client::github_download::{GithubBinaryMetadata, download_server_binary};
 use parking_lot::Mutex;
 use std::str::FromStr;
@@ -53,7 +52,7 @@ use task::{ShellKind, TaskTemplate, TaskTemplates, VariableName};
 use util::{ResultExt, maybe};
 
 pub(crate) fn semantic_token_rules() -> SemanticTokenRules {
-    let content = LanguageDir::get("python/semantic_token_rules.json")
+    let content = grammars::get_file("python/semantic_token_rules.json")
         .expect("missing python/semantic_token_rules.json");
     let json = std::str::from_utf8(&content.data).expect("invalid utf-8 in semantic_token_rules");
     settings::parse_json_with_comments::<SemanticTokenRules>(json)
@@ -1121,7 +1120,15 @@ fn python_env_kind_display(k: &PythonEnvironmentKind) -> &'static str {
     }
 }
 
-pub(crate) struct PythonToolchainProvider;
+pub(crate) struct PythonToolchainProvider {
+    fs: Arc<dyn Fs>,
+}
+
+impl PythonToolchainProvider {
+    pub fn new(fs: Arc<dyn Fs>) -> Self {
+        Self { fs }
+    }
+}
 
 static ENV_PRIORITY_LIST: &[PythonEnvironmentKind] = &[
     // Prioritize non-Conda environments.
@@ -1236,8 +1243,8 @@ impl ToolchainLister for PythonToolchainProvider {
         worktree_root: PathBuf,
         subroot_relative_path: Arc<RelPath>,
         project_env: Option<HashMap<String, String>>,
-        fs: &dyn Fs,
     ) -> ToolchainList {
+        let fs = &*self.fs;
         let env = project_env.unwrap_or_default();
         let environment = EnvironmentApi::from_env(&env);
         let locators = pet::locators::create_locators(
@@ -1368,8 +1375,8 @@ impl ToolchainLister for PythonToolchainProvider {
         &self,
         path: PathBuf,
         env: Option<HashMap<String, String>>,
-        fs: &dyn Fs,
     ) -> anyhow::Result<Toolchain> {
+        let fs = &*self.fs;
         let env = env.unwrap_or_default();
         let environment = EnvironmentApi::from_env(&env);
         let locators = pet::locators::create_locators(
@@ -2664,7 +2671,8 @@ mod tests {
             });
         });
 
-        let provider = PythonToolchainProvider;
+        let fs = project::FakeFs::new(cx.executor());
+        let provider = PythonToolchainProvider::new(fs);
         let malicious_name = "foo; rm -rf /";
 
         let manager_executable = std::env::current_exe().unwrap();

crates/languages/src/rust.rs πŸ”—

@@ -31,11 +31,10 @@ use util::merge_json_value_into;
 use util::rel_path::RelPath;
 use util::{ResultExt, maybe};
 
-use crate::LanguageDir;
 use crate::language_settings::LanguageSettings;
 
 pub(crate) fn semantic_token_rules() -> SemanticTokenRules {
-    let content = LanguageDir::get("rust/semantic_token_rules.json")
+    let content = grammars::get_file("rust/semantic_token_rules.json")
         .expect("missing rust/semantic_token_rules.json");
     let json = std::str::from_utf8(&content.data).expect("invalid utf-8 in semantic_token_rules");
     settings::parse_json_with_comments::<SemanticTokenRules>(json)
@@ -263,12 +262,7 @@ impl LspAdapter for RustLspAdapter {
         Some("rust-analyzer/flycheck".into())
     }
 
-    fn process_diagnostics(
-        &self,
-        params: &mut lsp::PublishDiagnosticsParams,
-        _: LanguageServerId,
-        _: Option<&'_ Buffer>,
-    ) {
+    fn process_diagnostics(&self, params: &mut lsp::PublishDiagnosticsParams, _: LanguageServerId) {
         static REGEX: LazyLock<Regex> =
             LazyLock::new(|| Regex::new(r"(?m)`([^`]+)\n`$").expect("Failed to create REGEX"));
 
@@ -1358,7 +1352,7 @@ mod tests {
                 },
             ],
         };
-        RustLspAdapter.process_diagnostics(&mut params, LanguageServerId(0), None);
+        RustLspAdapter.process_diagnostics(&mut params, LanguageServerId(0));
 
         assert_eq!(params.diagnostics[0].message, "use of moved value `a`");
 

crates/markdown/Cargo.toml πŸ”—

@@ -19,15 +19,20 @@ test-support = [
 ]
 
 [dependencies]
+anyhow.workspace = true
 base64.workspace = true
 collections.workspace = true
 futures.workspace = true
 gpui.workspace = true
+html5ever.workspace = true
 language.workspace = true
 linkify.workspace = true
 log.workspace = true
+markup5ever_rcdom.workspace = true
+mermaid-rs-renderer.workspace = true
 pulldown-cmark.workspace = true
 settings.workspace = true
+stacksafe.workspace = true
 sum_tree.workspace = true
 theme.workspace = true
 ui.workspace = true

crates/markdown/src/html/html_parser.rs πŸ”—

@@ -0,0 +1,883 @@
+use std::{cell::RefCell, collections::HashMap, mem, ops::Range};
+
+use gpui::{DefiniteLength, FontWeight, SharedString, px, relative};
+use html5ever::{
+    Attribute, LocalName, ParseOpts, local_name, parse_document, tendril::TendrilSink,
+};
+use markup5ever_rcdom::{Node, NodeData, RcDom};
+use pulldown_cmark::{Alignment, HeadingLevel};
+use stacksafe::stacksafe;
+
+use crate::html::html_minifier::{Minifier, MinifierOptions};
+
+#[derive(Debug, Clone, Default)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) struct ParsedHtmlBlock {
+    pub source_range: Range<usize>,
+    pub children: Vec<ParsedHtmlElement>,
+}
+
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) enum ParsedHtmlElement {
+    Heading(ParsedHtmlHeading),
+    List(ParsedHtmlList),
+    Table(ParsedHtmlTable),
+    BlockQuote(ParsedHtmlBlockQuote),
+    Paragraph(HtmlParagraph),
+    Image(HtmlImage),
+}
+
+impl ParsedHtmlElement {
+    pub fn source_range(&self) -> Option<Range<usize>> {
+        Some(match self {
+            Self::Heading(heading) => heading.source_range.clone(),
+            Self::List(list) => list.source_range.clone(),
+            Self::Table(table) => table.source_range.clone(),
+            Self::BlockQuote(block_quote) => block_quote.source_range.clone(),
+            Self::Paragraph(text) => match text.first()? {
+                HtmlParagraphChunk::Text(text) => text.source_range.clone(),
+                HtmlParagraphChunk::Image(image) => image.source_range.clone(),
+            },
+            Self::Image(image) => image.source_range.clone(),
+        })
+    }
+}
+
+pub(crate) type HtmlParagraph = Vec<HtmlParagraphChunk>;
+
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) enum HtmlParagraphChunk {
+    Text(ParsedHtmlText),
+    Image(HtmlImage),
+}
+
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) struct ParsedHtmlList {
+    pub source_range: Range<usize>,
+    pub depth: u16,
+    pub ordered: bool,
+    pub items: Vec<ParsedHtmlListItem>,
+}
+
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) struct ParsedHtmlListItem {
+    pub source_range: Range<usize>,
+    pub item_type: ParsedHtmlListItemType,
+    pub content: Vec<ParsedHtmlElement>,
+}
+
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) enum ParsedHtmlListItemType {
+    Ordered(u64),
+    Unordered,
+}
+
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) struct ParsedHtmlHeading {
+    pub source_range: Range<usize>,
+    pub level: HeadingLevel,
+    pub contents: HtmlParagraph,
+}
+
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) struct ParsedHtmlTable {
+    pub source_range: Range<usize>,
+    pub header: Vec<ParsedHtmlTableRow>,
+    pub body: Vec<ParsedHtmlTableRow>,
+    pub caption: Option<HtmlParagraph>,
+}
+
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) struct ParsedHtmlTableColumn {
+    pub col_span: usize,
+    pub row_span: usize,
+    pub is_header: bool,
+    pub children: HtmlParagraph,
+    pub alignment: Alignment,
+}
+
+#[derive(Debug, Clone, Default)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) struct ParsedHtmlTableRow {
+    pub columns: Vec<ParsedHtmlTableColumn>,
+}
+
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) struct ParsedHtmlBlockQuote {
+    pub source_range: Range<usize>,
+    pub children: Vec<ParsedHtmlElement>,
+}
+
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) struct ParsedHtmlText {
+    pub source_range: Range<usize>,
+    pub contents: SharedString,
+    pub highlights: Vec<(Range<usize>, HtmlHighlightStyle)>,
+    pub links: Vec<(Range<usize>, SharedString)>,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub(crate) struct HtmlHighlightStyle {
+    pub italic: bool,
+    pub underline: bool,
+    pub strikethrough: bool,
+    pub weight: FontWeight,
+    pub link: bool,
+    pub oblique: bool,
+}
+
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) struct HtmlImage {
+    pub dest_url: SharedString,
+    pub source_range: Range<usize>,
+    pub alt_text: Option<SharedString>,
+    pub width: Option<DefiniteLength>,
+    pub height: Option<DefiniteLength>,
+}
+
+impl HtmlImage {
+    fn new(dest_url: String, source_range: Range<usize>) -> Self {
+        Self {
+            dest_url: dest_url.into(),
+            source_range,
+            alt_text: None,
+            width: None,
+            height: None,
+        }
+    }
+
+    fn set_alt_text(&mut self, alt_text: SharedString) {
+        self.alt_text = Some(alt_text);
+    }
+
+    fn set_width(&mut self, width: DefiniteLength) {
+        self.width = Some(width);
+    }
+
+    fn set_height(&mut self, height: DefiniteLength) {
+        self.height = Some(height);
+    }
+}
+
+#[derive(Debug)]
+struct ParseHtmlNodeContext {
+    list_item_depth: u16,
+}
+
+impl Default for ParseHtmlNodeContext {
+    fn default() -> Self {
+        Self { list_item_depth: 1 }
+    }
+}
+
+pub(crate) fn parse_html_block(
+    source: &str,
+    source_range: Range<usize>,
+) -> Option<ParsedHtmlBlock> {
+    let bytes = cleanup_html(source);
+    let mut cursor = std::io::Cursor::new(bytes);
+    let dom = parse_document(RcDom::default(), ParseOpts::default())
+        .from_utf8()
+        .read_from(&mut cursor)
+        .ok()?;
+
+    let mut children = Vec::new();
+    parse_html_node(
+        source_range.clone(),
+        &dom.document,
+        &mut children,
+        &ParseHtmlNodeContext::default(),
+    );
+
+    Some(ParsedHtmlBlock {
+        source_range,
+        children,
+    })
+}
+
+fn cleanup_html(source: &str) -> Vec<u8> {
+    let mut writer = std::io::Cursor::new(Vec::new());
+    let mut reader = std::io::Cursor::new(source);
+    let mut minify = Minifier::new(
+        &mut writer,
+        MinifierOptions {
+            omit_doctype: true,
+            collapse_whitespace: true,
+            ..Default::default()
+        },
+    );
+    if let Ok(()) = minify.minify(&mut reader) {
+        writer.into_inner()
+    } else {
+        source.bytes().collect()
+    }
+}
+
+#[stacksafe]
+fn parse_html_node(
+    source_range: Range<usize>,
+    node: &Node,
+    elements: &mut Vec<ParsedHtmlElement>,
+    context: &ParseHtmlNodeContext,
+) {
+    match &node.data {
+        NodeData::Document => {
+            consume_children(source_range, node, elements, context);
+        }
+        NodeData::Text { contents } => {
+            elements.push(ParsedHtmlElement::Paragraph(vec![
+                HtmlParagraphChunk::Text(ParsedHtmlText {
+                    source_range,
+                    highlights: Vec::default(),
+                    links: Vec::default(),
+                    contents: contents.borrow().to_string().into(),
+                }),
+            ]));
+        }
+        NodeData::Comment { .. } => {}
+        NodeData::Element { name, attrs, .. } => {
+            let mut styles = if let Some(styles) =
+                html_style_from_html_styles(extract_styles_from_attributes(attrs))
+            {
+                vec![styles]
+            } else {
+                Vec::default()
+            };
+
+            if name.local == local_name!("img") {
+                if let Some(image) = extract_image(source_range, attrs) {
+                    elements.push(ParsedHtmlElement::Image(image));
+                }
+            } else if name.local == local_name!("p") {
+                let mut paragraph = HtmlParagraph::new();
+                parse_paragraph(
+                    source_range,
+                    node,
+                    &mut paragraph,
+                    &mut styles,
+                    &mut Vec::new(),
+                );
+
+                if !paragraph.is_empty() {
+                    elements.push(ParsedHtmlElement::Paragraph(paragraph));
+                }
+            } else if matches!(
+                name.local,
+                local_name!("h1")
+                    | local_name!("h2")
+                    | local_name!("h3")
+                    | local_name!("h4")
+                    | local_name!("h5")
+                    | local_name!("h6")
+            ) {
+                let mut paragraph = HtmlParagraph::new();
+                consume_paragraph(
+                    source_range.clone(),
+                    node,
+                    &mut paragraph,
+                    &mut styles,
+                    &mut Vec::new(),
+                );
+
+                if !paragraph.is_empty() {
+                    elements.push(ParsedHtmlElement::Heading(ParsedHtmlHeading {
+                        source_range,
+                        level: match name.local {
+                            local_name!("h1") => HeadingLevel::H1,
+                            local_name!("h2") => HeadingLevel::H2,
+                            local_name!("h3") => HeadingLevel::H3,
+                            local_name!("h4") => HeadingLevel::H4,
+                            local_name!("h5") => HeadingLevel::H5,
+                            local_name!("h6") => HeadingLevel::H6,
+                            _ => unreachable!(),
+                        },
+                        contents: paragraph,
+                    }));
+                }
+            } else if name.local == local_name!("ul") || name.local == local_name!("ol") {
+                if let Some(list) = extract_html_list(
+                    node,
+                    name.local == local_name!("ol"),
+                    context.list_item_depth,
+                    source_range,
+                ) {
+                    elements.push(ParsedHtmlElement::List(list));
+                }
+            } else if name.local == local_name!("blockquote") {
+                if let Some(blockquote) = extract_html_blockquote(node, source_range) {
+                    elements.push(ParsedHtmlElement::BlockQuote(blockquote));
+                }
+            } else if name.local == local_name!("table") {
+                if let Some(table) = extract_html_table(node, source_range) {
+                    elements.push(ParsedHtmlElement::Table(table));
+                }
+            } else {
+                consume_children(source_range, node, elements, context);
+            }
+        }
+        _ => {}
+    }
+}
+
+#[stacksafe]
+fn parse_paragraph(
+    source_range: Range<usize>,
+    node: &Node,
+    paragraph: &mut HtmlParagraph,
+    highlights: &mut Vec<HtmlHighlightStyle>,
+    links: &mut Vec<SharedString>,
+) {
+    fn items_with_range<T>(
+        range: Range<usize>,
+        items: impl IntoIterator<Item = T>,
+    ) -> Vec<(Range<usize>, T)> {
+        items
+            .into_iter()
+            .map(|item| (range.clone(), item))
+            .collect()
+    }
+
+    match &node.data {
+        NodeData::Text { contents } => {
+            if let Some(text) =
+                paragraph
+                    .iter_mut()
+                    .last()
+                    .and_then(|paragraph_chunk| match paragraph_chunk {
+                        HtmlParagraphChunk::Text(text) => Some(text),
+                        _ => None,
+                    })
+            {
+                let mut new_text = text.contents.to_string();
+                new_text.push_str(&contents.borrow());
+
+                text.highlights.extend(items_with_range(
+                    text.contents.len()..new_text.len(),
+                    mem::take(highlights),
+                ));
+                text.links.extend(items_with_range(
+                    text.contents.len()..new_text.len(),
+                    mem::take(links),
+                ));
+                text.contents = SharedString::from(new_text);
+            } else {
+                let contents = contents.borrow().to_string();
+                paragraph.push(HtmlParagraphChunk::Text(ParsedHtmlText {
+                    source_range,
+                    highlights: items_with_range(0..contents.len(), mem::take(highlights)),
+                    links: items_with_range(0..contents.len(), mem::take(links)),
+                    contents: contents.into(),
+                }));
+            }
+        }
+        NodeData::Element { name, attrs, .. } => {
+            if name.local == local_name!("img") {
+                if let Some(image) = extract_image(source_range, attrs) {
+                    paragraph.push(HtmlParagraphChunk::Image(image));
+                }
+            } else if name.local == local_name!("b") || name.local == local_name!("strong") {
+                highlights.push(HtmlHighlightStyle {
+                    weight: FontWeight::BOLD,
+                    ..Default::default()
+                });
+                consume_paragraph(source_range, node, paragraph, highlights, links);
+            } else if name.local == local_name!("i") {
+                highlights.push(HtmlHighlightStyle {
+                    italic: true,
+                    ..Default::default()
+                });
+                consume_paragraph(source_range, node, paragraph, highlights, links);
+            } else if name.local == local_name!("em") {
+                highlights.push(HtmlHighlightStyle {
+                    oblique: true,
+                    ..Default::default()
+                });
+                consume_paragraph(source_range, node, paragraph, highlights, links);
+            } else if name.local == local_name!("del") {
+                highlights.push(HtmlHighlightStyle {
+                    strikethrough: true,
+                    ..Default::default()
+                });
+                consume_paragraph(source_range, node, paragraph, highlights, links);
+            } else if name.local == local_name!("ins") {
+                highlights.push(HtmlHighlightStyle {
+                    underline: true,
+                    ..Default::default()
+                });
+                consume_paragraph(source_range, node, paragraph, highlights, links);
+            } else if name.local == local_name!("a") {
+                if let Some(url) = attr_value(attrs, local_name!("href")) {
+                    highlights.push(HtmlHighlightStyle {
+                        link: true,
+                        ..Default::default()
+                    });
+                    links.push(url.into());
+                }
+                consume_paragraph(source_range, node, paragraph, highlights, links);
+            } else {
+                consume_paragraph(source_range, node, paragraph, highlights, links);
+            }
+        }
+        _ => {}
+    }
+}
+
+fn consume_paragraph(
+    source_range: Range<usize>,
+    node: &Node,
+    paragraph: &mut HtmlParagraph,
+    highlights: &mut Vec<HtmlHighlightStyle>,
+    links: &mut Vec<SharedString>,
+) {
+    for child in node.children.borrow().iter() {
+        parse_paragraph(source_range.clone(), child, paragraph, highlights, links);
+    }
+}
+
+fn parse_table_row(source_range: Range<usize>, node: &Node) -> Option<ParsedHtmlTableRow> {
+    let mut columns = Vec::new();
+
+    if let NodeData::Element { name, .. } = &node.data {
+        if name.local != local_name!("tr") {
+            return None;
+        }
+
+        for child in node.children.borrow().iter() {
+            if let Some(column) = parse_table_column(source_range.clone(), child) {
+                columns.push(column);
+            }
+        }
+    }
+
+    if columns.is_empty() {
+        None
+    } else {
+        Some(ParsedHtmlTableRow { columns })
+    }
+}
+
+fn parse_table_column(source_range: Range<usize>, node: &Node) -> Option<ParsedHtmlTableColumn> {
+    match &node.data {
+        NodeData::Element { name, attrs, .. } => {
+            if !matches!(name.local, local_name!("th") | local_name!("td")) {
+                return None;
+            }
+
+            let mut children = HtmlParagraph::new();
+            consume_paragraph(
+                source_range,
+                node,
+                &mut children,
+                &mut Vec::new(),
+                &mut Vec::new(),
+            );
+
+            let is_header = name.local == local_name!("th");
+
+            Some(ParsedHtmlTableColumn {
+                col_span: std::cmp::max(
+                    attr_value(attrs, local_name!("colspan"))
+                        .and_then(|span| span.parse().ok())
+                        .unwrap_or(1),
+                    1,
+                ),
+                row_span: std::cmp::max(
+                    attr_value(attrs, local_name!("rowspan"))
+                        .and_then(|span| span.parse().ok())
+                        .unwrap_or(1),
+                    1,
+                ),
+                is_header,
+                children,
+                alignment: attr_value(attrs, local_name!("align"))
+                    .and_then(|align| match align.as_str() {
+                        "left" => Some(Alignment::Left),
+                        "center" => Some(Alignment::Center),
+                        "right" => Some(Alignment::Right),
+                        _ => None,
+                    })
+                    .unwrap_or(if is_header {
+                        Alignment::Center
+                    } else {
+                        Alignment::None
+                    }),
+            })
+        }
+        _ => None,
+    }
+}
+
+fn consume_children(
+    source_range: Range<usize>,
+    node: &Node,
+    elements: &mut Vec<ParsedHtmlElement>,
+    context: &ParseHtmlNodeContext,
+) {
+    for child in node.children.borrow().iter() {
+        parse_html_node(source_range.clone(), child, elements, context);
+    }
+}
+
+fn attr_value(attrs: &RefCell<Vec<Attribute>>, name: LocalName) -> Option<String> {
+    attrs.borrow().iter().find_map(|attr| {
+        if attr.name.local == name {
+            Some(attr.value.to_string())
+        } else {
+            None
+        }
+    })
+}
+
+fn html_style_from_html_styles(styles: HashMap<String, String>) -> Option<HtmlHighlightStyle> {
+    let mut html_style = HtmlHighlightStyle::default();
+
+    if let Some(text_decoration) = styles.get("text-decoration") {
+        match text_decoration.to_lowercase().as_str() {
+            "underline" => {
+                html_style.underline = true;
+            }
+            "line-through" => {
+                html_style.strikethrough = true;
+            }
+            _ => {}
+        }
+    }
+
+    if let Some(font_style) = styles.get("font-style") {
+        match font_style.to_lowercase().as_str() {
+            "italic" => {
+                html_style.italic = true;
+            }
+            "oblique" => {
+                html_style.oblique = true;
+            }
+            _ => {}
+        }
+    }
+
+    if let Some(font_weight) = styles.get("font-weight") {
+        match font_weight.to_lowercase().as_str() {
+            "bold" => {
+                html_style.weight = FontWeight::BOLD;
+            }
+            "lighter" => {
+                html_style.weight = FontWeight::THIN;
+            }
+            _ => {
+                if let Ok(weight) = font_weight.parse::<f32>() {
+                    html_style.weight = FontWeight(weight);
+                }
+            }
+        }
+    }
+
+    if html_style != HtmlHighlightStyle::default() {
+        Some(html_style)
+    } else {
+        None
+    }
+}
+
+fn extract_styles_from_attributes(attrs: &RefCell<Vec<Attribute>>) -> HashMap<String, String> {
+    let mut styles = HashMap::new();
+
+    if let Some(style) = attr_value(attrs, local_name!("style")) {
+        for declaration in style.split(';') {
+            let mut parts = declaration.splitn(2, ':');
+            if let Some((key, value)) = parts.next().zip(parts.next()) {
+                styles.insert(key.trim().to_lowercase(), value.trim().to_string());
+            }
+        }
+    }
+
+    styles
+}
+
+fn extract_image(source_range: Range<usize>, attrs: &RefCell<Vec<Attribute>>) -> Option<HtmlImage> {
+    let src = attr_value(attrs, local_name!("src"))?;
+
+    let mut image = HtmlImage::new(src, source_range);
+
+    if let Some(alt) = attr_value(attrs, local_name!("alt")) {
+        image.set_alt_text(alt.into());
+    }
+
+    let styles = extract_styles_from_attributes(attrs);
+
+    if let Some(width) = attr_value(attrs, local_name!("width"))
+        .or_else(|| styles.get("width").cloned())
+        .and_then(|width| parse_html_element_dimension(&width))
+    {
+        image.set_width(width);
+    }
+
+    if let Some(height) = attr_value(attrs, local_name!("height"))
+        .or_else(|| styles.get("height").cloned())
+        .and_then(|height| parse_html_element_dimension(&height))
+    {
+        image.set_height(height);
+    }
+
+    Some(image)
+}
+
+fn extract_html_list(
+    node: &Node,
+    ordered: bool,
+    depth: u16,
+    source_range: Range<usize>,
+) -> Option<ParsedHtmlList> {
+    let mut items = Vec::with_capacity(node.children.borrow().len());
+
+    for (index, child) in node.children.borrow().iter().enumerate() {
+        if let NodeData::Element { name, .. } = &child.data {
+            if name.local != local_name!("li") {
+                continue;
+            }
+
+            let mut content = Vec::new();
+            consume_children(
+                source_range.clone(),
+                child,
+                &mut content,
+                &ParseHtmlNodeContext {
+                    list_item_depth: depth + 1,
+                },
+            );
+
+            if !content.is_empty() {
+                items.push(ParsedHtmlListItem {
+                    source_range: source_range.clone(),
+                    item_type: if ordered {
+                        ParsedHtmlListItemType::Ordered(index as u64 + 1)
+                    } else {
+                        ParsedHtmlListItemType::Unordered
+                    },
+                    content,
+                });
+            }
+        }
+    }
+
+    if items.is_empty() {
+        None
+    } else {
+        Some(ParsedHtmlList {
+            source_range,
+            depth,
+            ordered,
+            items,
+        })
+    }
+}
+
+fn parse_html_element_dimension(value: &str) -> Option<DefiniteLength> {
+    if value.ends_with('%') {
+        value
+            .trim_end_matches('%')
+            .parse::<f32>()
+            .ok()
+            .map(|value| relative(value / 100.))
+    } else {
+        value
+            .trim_end_matches("px")
+            .parse()
+            .ok()
+            .map(|value| px(value).into())
+    }
+}
+
+fn extract_html_blockquote(
+    node: &Node,
+    source_range: Range<usize>,
+) -> Option<ParsedHtmlBlockQuote> {
+    let mut children = Vec::new();
+    consume_children(
+        source_range.clone(),
+        node,
+        &mut children,
+        &ParseHtmlNodeContext::default(),
+    );
+
+    if children.is_empty() {
+        None
+    } else {
+        Some(ParsedHtmlBlockQuote {
+            children,
+            source_range,
+        })
+    }
+}
+
+fn extract_html_table(node: &Node, source_range: Range<usize>) -> Option<ParsedHtmlTable> {
+    let mut header_rows = Vec::new();
+    let mut body_rows = Vec::new();
+    let mut caption = None;
+
+    for child in node.children.borrow().iter() {
+        if let NodeData::Element { name, .. } = &child.data {
+            if name.local == local_name!("caption") {
+                let mut paragraph = HtmlParagraph::new();
+                parse_paragraph(
+                    source_range.clone(),
+                    child,
+                    &mut paragraph,
+                    &mut Vec::new(),
+                    &mut Vec::new(),
+                );
+                caption = Some(paragraph);
+            }
+
+            if name.local == local_name!("thead") {
+                for row in child.children.borrow().iter() {
+                    if let Some(row) = parse_table_row(source_range.clone(), row) {
+                        header_rows.push(row);
+                    }
+                }
+            } else if name.local == local_name!("tbody") {
+                for row in child.children.borrow().iter() {
+                    if let Some(row) = parse_table_row(source_range.clone(), row) {
+                        body_rows.push(row);
+                    }
+                }
+            }
+        }
+    }
+
+    if !header_rows.is_empty() || !body_rows.is_empty() {
+        Some(ParsedHtmlTable {
+            source_range,
+            body: body_rows,
+            header: header_rows,
+            caption,
+        })
+    } else {
+        None
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parses_html_styled_text() {
+        let parsed = parse_html_block(
+            "<p>Some text <strong>strong</strong> <a href=\"https://example.com\">link</a></p>",
+            0..79,
+        )
+        .unwrap();
+
+        assert_eq!(parsed.children.len(), 1);
+        let ParsedHtmlElement::Paragraph(paragraph) = &parsed.children[0] else {
+            panic!("expected paragraph");
+        };
+        let HtmlParagraphChunk::Text(text) = &paragraph[0] else {
+            panic!("expected text chunk");
+        };
+
+        assert_eq!(text.contents.as_ref(), "Some text strong link");
+        assert_eq!(
+            text.highlights,
+            vec![
+                (
+                    10..16,
+                    HtmlHighlightStyle {
+                        weight: FontWeight::BOLD,
+                        ..Default::default()
+                    }
+                ),
+                (
+                    17..21,
+                    HtmlHighlightStyle {
+                        link: true,
+                        ..Default::default()
+                    }
+                )
+            ]
+        );
+        assert_eq!(
+            text.links,
+            vec![(17..21, SharedString::from("https://example.com"))]
+        );
+    }
+
+    #[test]
+    fn parses_html_table_spans() {
+        let parsed = parse_html_block(
+            "<table><tbody><tr><td colspan=\"2\">a</td></tr><tr><td>b</td><td>c</td></tr></tbody></table>",
+            0..91,
+        )
+        .unwrap();
+
+        let ParsedHtmlElement::Table(table) = &parsed.children[0] else {
+            panic!("expected table");
+        };
+        assert_eq!(table.body.len(), 2);
+        assert_eq!(table.body[0].columns[0].col_span, 2);
+        assert_eq!(table.body[1].columns.len(), 2);
+    }
+
+    #[test]
+    fn parses_html_list_as_explicit_list_node() {
+        let parsed = parse_html_block(
+            "<ul><li>parent<ul><li>child</li></ul></li><li>sibling</li></ul>",
+            0..64,
+        )
+        .unwrap();
+
+        assert_eq!(parsed.children.len(), 1);
+
+        let ParsedHtmlElement::List(list) = &parsed.children[0] else {
+            panic!("expected list");
+        };
+
+        assert!(!list.ordered);
+        assert_eq!(list.depth, 1);
+        assert_eq!(list.items.len(), 2);
+
+        let first_item = &list.items[0];
+        let ParsedHtmlElement::Paragraph(paragraph) = &first_item.content[0] else {
+            panic!("expected first item paragraph");
+        };
+        let HtmlParagraphChunk::Text(text) = &paragraph[0] else {
+            panic!("expected first item text");
+        };
+        assert_eq!(text.contents.as_ref(), "parent");
+
+        let ParsedHtmlElement::List(nested_list) = &first_item.content[1] else {
+            panic!("expected nested list");
+        };
+        assert_eq!(nested_list.depth, 2);
+        assert_eq!(nested_list.items.len(), 1);
+
+        let ParsedHtmlElement::Paragraph(nested_paragraph) = &nested_list.items[0].content[0]
+        else {
+            panic!("expected nested item paragraph");
+        };
+        let HtmlParagraphChunk::Text(nested_text) = &nested_paragraph[0] else {
+            panic!("expected nested item text");
+        };
+        assert_eq!(nested_text.contents.as_ref(), "child");
+
+        let second_item = &list.items[1];
+        let ParsedHtmlElement::Paragraph(second_paragraph) = &second_item.content[0] else {
+            panic!("expected second item paragraph");
+        };
+        let HtmlParagraphChunk::Text(second_text) = &second_paragraph[0] else {
+            panic!("expected second item text");
+        };
+        assert_eq!(second_text.contents.as_ref(), "sibling");
+    }
+}

crates/markdown/src/html/html_rendering.rs πŸ”—

@@ -0,0 +1,613 @@
+use std::ops::Range;
+
+use gpui::{App, FontStyle, FontWeight, StrikethroughStyle, TextStyleRefinement, UnderlineStyle};
+use pulldown_cmark::Alignment;
+use ui::prelude::*;
+
+use crate::html::html_parser::{
+    HtmlHighlightStyle, HtmlImage, HtmlParagraph, HtmlParagraphChunk, ParsedHtmlBlock,
+    ParsedHtmlElement, ParsedHtmlList, ParsedHtmlListItemType, ParsedHtmlTable, ParsedHtmlTableRow,
+    ParsedHtmlText,
+};
+use crate::{MarkdownElement, MarkdownElementBuilder};
+
+pub(crate) struct HtmlSourceAllocator {
+    source_range: Range<usize>,
+    next_source_index: usize,
+}
+
+impl HtmlSourceAllocator {
+    pub(crate) fn new(source_range: Range<usize>) -> Self {
+        Self {
+            next_source_index: source_range.start,
+            source_range,
+        }
+    }
+
+    pub(crate) fn allocate(&mut self, requested_len: usize) -> Range<usize> {
+        let remaining = self.source_range.end.saturating_sub(self.next_source_index);
+        let len = requested_len.min(remaining);
+        let start = self.next_source_index;
+        let end = start + len;
+        self.next_source_index = end;
+        start..end
+    }
+}
+
+impl MarkdownElement {
+    pub(crate) fn render_html_block(
+        &self,
+        block: &ParsedHtmlBlock,
+        builder: &mut MarkdownElementBuilder,
+        markdown_end: usize,
+        cx: &mut App,
+    ) {
+        let mut source_allocator = HtmlSourceAllocator::new(block.source_range.clone());
+        self.render_html_elements(
+            &block.children,
+            &mut source_allocator,
+            builder,
+            markdown_end,
+            cx,
+        );
+    }
+
+    fn render_html_elements(
+        &self,
+        elements: &[ParsedHtmlElement],
+        source_allocator: &mut HtmlSourceAllocator,
+        builder: &mut MarkdownElementBuilder,
+        markdown_end: usize,
+        cx: &mut App,
+    ) {
+        for element in elements {
+            self.render_html_element(element, source_allocator, builder, markdown_end, cx);
+        }
+    }
+
+    fn render_html_element(
+        &self,
+        element: &ParsedHtmlElement,
+        source_allocator: &mut HtmlSourceAllocator,
+        builder: &mut MarkdownElementBuilder,
+        markdown_end: usize,
+        cx: &mut App,
+    ) {
+        let Some(source_range) = element.source_range() else {
+            return;
+        };
+
+        match element {
+            ParsedHtmlElement::Paragraph(paragraph) => {
+                self.push_markdown_paragraph(builder, &source_range, markdown_end);
+                self.render_html_paragraph(paragraph, source_allocator, builder, cx, markdown_end);
+                builder.pop_div();
+            }
+            ParsedHtmlElement::Heading(heading) => {
+                self.push_markdown_heading(
+                    builder,
+                    heading.level,
+                    &heading.source_range,
+                    markdown_end,
+                );
+                self.render_html_paragraph(
+                    &heading.contents,
+                    source_allocator,
+                    builder,
+                    cx,
+                    markdown_end,
+                );
+                self.pop_markdown_heading(builder);
+            }
+            ParsedHtmlElement::List(list) => {
+                self.render_html_list(list, source_allocator, builder, markdown_end, cx);
+            }
+            ParsedHtmlElement::BlockQuote(block_quote) => {
+                self.push_markdown_block_quote(builder, &block_quote.source_range, markdown_end);
+                self.render_html_elements(
+                    &block_quote.children,
+                    source_allocator,
+                    builder,
+                    markdown_end,
+                    cx,
+                );
+                self.pop_markdown_block_quote(builder);
+            }
+            ParsedHtmlElement::Table(table) => {
+                self.render_html_table(table, source_allocator, builder, markdown_end, cx);
+            }
+            ParsedHtmlElement::Image(image) => {
+                self.render_html_image(image, builder);
+            }
+        }
+    }
+
+    fn render_html_list(
+        &self,
+        list: &ParsedHtmlList,
+        source_allocator: &mut HtmlSourceAllocator,
+        builder: &mut MarkdownElementBuilder,
+        markdown_end: usize,
+        cx: &mut App,
+    ) {
+        builder.push_div(div().pl_2p5(), &list.source_range, markdown_end);
+
+        for list_item in &list.items {
+            let bullet = match list_item.item_type {
+                ParsedHtmlListItemType::Ordered(order) => html_list_item_prefix(
+                    order as usize,
+                    list.ordered,
+                    list.depth.saturating_sub(1) as usize,
+                ),
+                ParsedHtmlListItemType::Unordered => {
+                    html_list_item_prefix(1, false, list.depth.saturating_sub(1) as usize)
+                }
+            };
+
+            self.push_markdown_list_item(
+                builder,
+                div().child(bullet).into_any_element(),
+                &list_item.source_range,
+                markdown_end,
+            );
+            self.render_html_elements(
+                &list_item.content,
+                source_allocator,
+                builder,
+                markdown_end,
+                cx,
+            );
+            self.pop_markdown_list_item(builder);
+        }
+
+        builder.pop_div();
+    }
+
+    fn render_html_table(
+        &self,
+        table: &ParsedHtmlTable,
+        source_allocator: &mut HtmlSourceAllocator,
+        builder: &mut MarkdownElementBuilder,
+        markdown_end: usize,
+        cx: &mut App,
+    ) {
+        if let Some(caption) = &table.caption {
+            builder.push_div(
+                div().when(!self.style.height_is_multiple_of_line_height, |el| {
+                    el.mb_2().line_height(rems(1.3))
+                }),
+                &table.source_range,
+                markdown_end,
+            );
+            self.render_html_paragraph(caption, source_allocator, builder, cx, markdown_end);
+            builder.pop_div();
+        }
+
+        let actual_header_column_count = html_table_columns_count(&table.header);
+        let actual_body_column_count = html_table_columns_count(&table.body);
+        let max_column_count = actual_header_column_count.max(actual_body_column_count);
+
+        if max_column_count == 0 {
+            return;
+        }
+
+        let total_rows = table.header.len() + table.body.len();
+        let mut grid_occupied = vec![vec![false; max_column_count]; total_rows];
+
+        builder.push_div(
+            div()
+                .id(("html-table", table.source_range.start))
+                .grid()
+                .grid_cols(max_column_count as u16)
+                .when(self.style.table_columns_min_size, |this| {
+                    this.grid_cols_min_content(max_column_count as u16)
+                })
+                .when(!self.style.table_columns_min_size, |this| {
+                    this.grid_cols(max_column_count as u16)
+                })
+                .w_full()
+                .mb_2()
+                .border(px(1.5))
+                .border_color(cx.theme().colors().border)
+                .rounded_sm()
+                .overflow_hidden(),
+            &table.source_range,
+            markdown_end,
+        );
+
+        for (row_index, row) in table.header.iter().chain(table.body.iter()).enumerate() {
+            let mut column_index = 0;
+
+            for cell in &row.columns {
+                while column_index < max_column_count && grid_occupied[row_index][column_index] {
+                    column_index += 1;
+                }
+
+                if column_index >= max_column_count {
+                    break;
+                }
+
+                let max_span = max_column_count.saturating_sub(column_index);
+                let mut cell_div = div()
+                    .col_span(cell.col_span.min(max_span) as u16)
+                    .row_span(cell.row_span.min(total_rows - row_index) as u16)
+                    .when(column_index > 0, |this| this.border_l_1())
+                    .when(row_index > 0, |this| this.border_t_1())
+                    .border_color(cx.theme().colors().border)
+                    .px_2()
+                    .py_1()
+                    .when(cell.is_header, |this| {
+                        this.bg(cx.theme().colors().title_bar_background)
+                    })
+                    .when(!cell.is_header && row_index % 2 == 1, |this| {
+                        this.bg(cx.theme().colors().panel_background)
+                    });
+
+                cell_div = match cell.alignment {
+                    Alignment::Center => cell_div.items_center(),
+                    Alignment::Right => cell_div.items_end(),
+                    _ => cell_div,
+                };
+
+                builder.push_div(cell_div, &table.source_range, markdown_end);
+                self.render_html_paragraph(
+                    &cell.children,
+                    source_allocator,
+                    builder,
+                    cx,
+                    markdown_end,
+                );
+                builder.pop_div();
+
+                for row_offset in 0..cell.row_span {
+                    for column_offset in 0..cell.col_span {
+                        if row_index + row_offset < total_rows
+                            && column_index + column_offset < max_column_count
+                        {
+                            grid_occupied[row_index + row_offset][column_index + column_offset] =
+                                true;
+                        }
+                    }
+                }
+
+                column_index += cell.col_span;
+            }
+
+            while column_index < max_column_count {
+                if grid_occupied[row_index][column_index] {
+                    column_index += 1;
+                    continue;
+                }
+
+                builder.push_div(
+                    div()
+                        .when(column_index > 0, |this| this.border_l_1())
+                        .when(row_index > 0, |this| this.border_t_1())
+                        .border_color(cx.theme().colors().border)
+                        .when(row_index % 2 == 1, |this| {
+                            this.bg(cx.theme().colors().panel_background)
+                        }),
+                    &table.source_range,
+                    markdown_end,
+                );
+                builder.pop_div();
+                column_index += 1;
+            }
+        }
+
+        builder.pop_div();
+    }
+
+    fn render_html_paragraph(
+        &self,
+        paragraph: &HtmlParagraph,
+        source_allocator: &mut HtmlSourceAllocator,
+        builder: &mut MarkdownElementBuilder,
+        cx: &mut App,
+        _markdown_end: usize,
+    ) {
+        for chunk in paragraph {
+            match chunk {
+                HtmlParagraphChunk::Text(text) => {
+                    self.render_html_text(text, source_allocator, builder, cx);
+                }
+                HtmlParagraphChunk::Image(image) => {
+                    self.render_html_image(image, builder);
+                }
+            }
+        }
+    }
+
+    fn render_html_text(
+        &self,
+        text: &ParsedHtmlText,
+        source_allocator: &mut HtmlSourceAllocator,
+        builder: &mut MarkdownElementBuilder,
+        cx: &mut App,
+    ) {
+        let text_contents = text.contents.as_ref();
+        if text_contents.is_empty() {
+            return;
+        }
+
+        let allocated_range = source_allocator.allocate(text_contents.len());
+        let allocated_len = allocated_range.end.saturating_sub(allocated_range.start);
+
+        let mut boundaries = vec![0, text_contents.len()];
+        for (range, _) in &text.highlights {
+            boundaries.push(range.start);
+            boundaries.push(range.end);
+        }
+        for (range, _) in &text.links {
+            boundaries.push(range.start);
+            boundaries.push(range.end);
+        }
+        boundaries.sort_unstable();
+        boundaries.dedup();
+
+        for segment in boundaries.windows(2) {
+            let start = segment[0];
+            let end = segment[1];
+            if start >= end {
+                continue;
+            }
+
+            let source_start = allocated_range.start + start.min(allocated_len);
+            let source_end = allocated_range.start + end.min(allocated_len);
+            if source_start >= source_end {
+                continue;
+            }
+
+            let mut refinement = TextStyleRefinement::default();
+            let mut has_refinement = false;
+
+            for (highlight_range, style) in &text.highlights {
+                if highlight_range.start < end && highlight_range.end > start {
+                    apply_html_highlight_style(&mut refinement, style);
+                    has_refinement = true;
+                }
+            }
+
+            let link = text.links.iter().find_map(|(link_range, link)| {
+                if link_range.start < end && link_range.end > start {
+                    Some(link.clone())
+                } else {
+                    None
+                }
+            });
+
+            if let Some(link) = link.as_ref() {
+                builder.push_link(link.clone(), source_start..source_end);
+                let link_style = self
+                    .style
+                    .link_callback
+                    .as_ref()
+                    .and_then(|callback| callback(link.as_ref(), cx))
+                    .unwrap_or_else(|| self.style.link.clone());
+                builder.push_text_style(link_style);
+            }
+
+            if has_refinement {
+                builder.push_text_style(refinement);
+            }
+
+            builder.push_text(&text_contents[start..end], source_start..source_end);
+
+            if has_refinement {
+                builder.pop_text_style();
+            }
+
+            if link.is_some() {
+                builder.pop_text_style();
+            }
+        }
+    }
+
+    fn render_html_image(&self, image: &HtmlImage, builder: &mut MarkdownElementBuilder) {
+        let Some(source) = self
+            .image_resolver
+            .as_ref()
+            .and_then(|resolve| resolve(image.dest_url.as_ref()))
+        else {
+            return;
+        };
+
+        self.push_markdown_image(
+            builder,
+            &image.source_range,
+            source,
+            image.width,
+            image.height,
+        );
+    }
+}
+
+fn apply_html_highlight_style(refinement: &mut TextStyleRefinement, style: &HtmlHighlightStyle) {
+    if style.weight != FontWeight::default() {
+        refinement.font_weight = Some(style.weight);
+    }
+
+    if style.oblique {
+        refinement.font_style = Some(FontStyle::Oblique);
+    } else if style.italic {
+        refinement.font_style = Some(FontStyle::Italic);
+    }
+
+    if style.underline {
+        refinement.underline = Some(UnderlineStyle {
+            thickness: px(1.),
+            color: None,
+            ..Default::default()
+        });
+    }
+
+    if style.strikethrough {
+        refinement.strikethrough = Some(StrikethroughStyle {
+            thickness: px(1.),
+            color: None,
+        });
+    }
+}
+
+fn html_list_item_prefix(order: usize, ordered: bool, depth: usize) -> String {
+    let index = order.saturating_sub(1);
+    const NUMBERED_PREFIXES_1: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+    const NUMBERED_PREFIXES_2: &str = "abcdefghijklmnopqrstuvwxyz";
+    const BULLETS: [&str; 5] = ["β€’", "β—¦", "β–ͺ", "β€£", "⁃"];
+
+    if ordered {
+        match depth {
+            0 => format!("{}. ", order),
+            1 => format!(
+                "{}. ",
+                NUMBERED_PREFIXES_1
+                    .chars()
+                    .nth(index % NUMBERED_PREFIXES_1.len())
+                    .unwrap()
+            ),
+            _ => format!(
+                "{}. ",
+                NUMBERED_PREFIXES_2
+                    .chars()
+                    .nth(index % NUMBERED_PREFIXES_2.len())
+                    .unwrap()
+            ),
+        }
+    } else {
+        let depth = depth.min(BULLETS.len() - 1);
+        format!("{} ", BULLETS[depth])
+    }
+}
+
+fn html_table_columns_count(rows: &[ParsedHtmlTableRow]) -> usize {
+    let mut actual_column_count = 0;
+    for row in rows {
+        actual_column_count = actual_column_count.max(
+            row.columns
+                .iter()
+                .map(|column| column.col_span)
+                .sum::<usize>(),
+        );
+    }
+    actual_column_count
+}
+
+#[cfg(test)]
+mod tests {
+    use gpui::{TestAppContext, size};
+    use ui::prelude::*;
+
+    use crate::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownOptions, MarkdownStyle};
+
+    fn ensure_theme_initialized(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            if !cx.has_global::<settings::SettingsStore>() {
+                settings::init(cx);
+            }
+            if !cx.has_global::<theme::GlobalTheme>() {
+                theme::init(theme::LoadThemes::JustBase, cx);
+            }
+        });
+    }
+
+    fn render_markdown_text(markdown: &str, cx: &mut TestAppContext) -> crate::RenderedText {
+        struct TestWindow;
+
+        impl Render for TestWindow {
+            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+                div()
+            }
+        }
+
+        ensure_theme_initialized(cx);
+
+        let (_, cx) = cx.add_window_view(|_, _| TestWindow);
+        let markdown = cx.new(|cx| Markdown::new(markdown.to_string().into(), None, None, cx));
+        cx.run_until_parked();
+        let (rendered, _) = cx.draw(
+            Default::default(),
+            size(px(600.0), px(600.0)),
+            |_window, _cx| {
+                MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
+                    CodeBlockRenderer::Default {
+                        copy_button: false,
+                        copy_button_on_hover: false,
+                        border: false,
+                    },
+                )
+            },
+        );
+        rendered.text
+    }
+
+    #[gpui::test]
+    fn test_html_block_rendering_smoke(cx: &mut TestAppContext) {
+        let rendered = render_markdown_text(
+            "<h1>Hello</h1><blockquote><p>world</p></blockquote><ul><li>item</li></ul>",
+            cx,
+        );
+
+        let rendered_lines = rendered
+            .lines
+            .iter()
+            .map(|line| line.layout.wrapped_text())
+            .collect::<Vec<_>>();
+
+        assert_eq!(
+            rendered_lines.concat().replace('\n', ""),
+            "<h1>Hello</h1><blockquote><p>world</p></blockquote><ul><li>item</li></ul>"
+        );
+    }
+
+    #[gpui::test]
+    fn test_html_block_rendering_can_be_enabled(cx: &mut TestAppContext) {
+        struct TestWindow;
+
+        impl Render for TestWindow {
+            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+                div()
+            }
+        }
+
+        ensure_theme_initialized(cx);
+
+        let (_, cx) = cx.add_window_view(|_, _| TestWindow);
+        let markdown = cx.new(|cx| {
+            Markdown::new_with_options(
+                "<h1>Hello</h1><blockquote><p>world</p></blockquote><ul><li>item</li></ul>".into(),
+                None,
+                None,
+                MarkdownOptions {
+                    parse_html: true,
+                    ..Default::default()
+                },
+                cx,
+            )
+        });
+        cx.run_until_parked();
+        let (rendered, _) = cx.draw(
+            Default::default(),
+            size(px(600.0), px(600.0)),
+            |_window, _cx| {
+                MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
+                    CodeBlockRenderer::Default {
+                        copy_button: false,
+                        copy_button_on_hover: false,
+                        border: false,
+                    },
+                )
+            },
+        );
+
+        let rendered_lines = rendered
+            .text
+            .lines
+            .iter()
+            .map(|line| line.layout.wrapped_text())
+            .collect::<Vec<_>>();
+
+        assert_eq!(rendered_lines[0], "Hello");
+        assert_eq!(rendered_lines[1], "world");
+        assert!(rendered_lines.iter().any(|line| line.contains("item")));
+    }
+}

crates/markdown/src/markdown.rs πŸ”—

@@ -1,3 +1,5 @@
+pub mod html;
+mod mermaid;
 pub mod parser;
 mod path_range;
 
@@ -7,7 +9,11 @@ use gpui::EdgesRefinement;
 use gpui::HitboxBehavior;
 use gpui::UnderlineStyle;
 use language::LanguageName;
+
 use log::Level;
+use mermaid::{
+    MermaidState, ParsedMarkdownMermaidDiagram, extract_mermaid_diagrams, render_mermaid_diagram,
+};
 pub use path_range::{LineCol, PathWithRange};
 use settings::Settings as _;
 use theme::ThemeSettings;
@@ -28,13 +34,16 @@ use collections::{HashMap, HashSet};
 use gpui::{
     AnyElement, App, BorderStyle, Bounds, ClipboardItem, CursorStyle, DispatchPhase, Edges, Entity,
     FocusHandle, Focusable, FontStyle, FontWeight, GlobalElementId, Hitbox, Hsla, Image,
-    ImageFormat, KeyContext, Length, MouseButton, MouseDownEvent, MouseEvent, MouseMoveEvent,
-    MouseUpEvent, Point, ScrollHandle, Stateful, StrikethroughStyle, StyleRefinement, StyledText,
-    Task, TextLayout, TextRun, TextStyle, TextStyleRefinement, actions, img, point, quad,
+    ImageFormat, ImageSource, KeyContext, Length, MouseButton, MouseDownEvent, MouseEvent,
+    MouseMoveEvent, MouseUpEvent, Point, ScrollHandle, Stateful, StrikethroughStyle,
+    StyleRefinement, StyledText, Task, TextLayout, TextRun, TextStyle, TextStyleRefinement,
+    actions, img, point, quad,
 };
 use language::{CharClassifier, Language, LanguageRegistry, Rope};
 use parser::CodeBlockMetadata;
-use parser::{MarkdownEvent, MarkdownTag, MarkdownTagEnd, parse_links_only, parse_markdown};
+use parser::{
+    MarkdownEvent, MarkdownTag, MarkdownTagEnd, parse_links_only, parse_markdown_with_options,
+};
 use pulldown_cmark::Alignment;
 use sum_tree::TreeMap;
 use theme::SyntaxTheme;
@@ -46,7 +55,8 @@ use crate::parser::CodeBlockKind;
 /// A callback function that can be used to customize the style of links based on the destination URL.
 /// If the callback returns `None`, the default link style will be used.
 type LinkStyleCallback = Rc<dyn Fn(&str, &App) -> Option<TextStyleRefinement>>;
-
+type SourceClickCallback = Box<dyn Fn(usize, usize, &mut Window, &mut App) -> bool>;
+type CheckboxToggleCallback = Rc<dyn Fn(Range<usize>, bool, &mut Window, &mut App)>;
 /// Defines custom style refinements for each heading level (H1-H6)
 #[derive(Clone, Default)]
 pub struct HeadingLevelStyles {
@@ -238,6 +248,7 @@ pub struct Markdown {
     selection: Selection,
     pressed_link: Option<RenderedLink>,
     autoscroll_request: Option<usize>,
+    active_root_block: Option<usize>,
     parsed_markdown: ParsedMarkdown,
     images_by_source_offset: HashMap<usize, Arc<Image>>,
     should_reparse: bool,
@@ -245,14 +256,18 @@ pub struct Markdown {
     focus_handle: FocusHandle,
     language_registry: Option<Arc<LanguageRegistry>>,
     fallback_code_block_language: Option<LanguageName>,
-    options: Options,
+    options: MarkdownOptions,
+    mermaid_state: MermaidState,
     copied_code_blocks: HashSet<ElementId>,
     code_block_scroll_handles: BTreeMap<usize, ScrollHandle>,
     context_menu_selected_text: Option<String>,
 }
 
-struct Options {
-    parse_links_only: bool,
+#[derive(Clone, Copy, Default)]
+pub struct MarkdownOptions {
+    pub parse_links_only: bool,
+    pub parse_html: bool,
+    pub render_mermaid_diagrams: bool,
 }
 
 pub enum CodeBlockRenderer {
@@ -299,6 +314,22 @@ impl Markdown {
         language_registry: Option<Arc<LanguageRegistry>>,
         fallback_code_block_language: Option<LanguageName>,
         cx: &mut Context<Self>,
+    ) -> Self {
+        Self::new_with_options(
+            source,
+            language_registry,
+            fallback_code_block_language,
+            MarkdownOptions::default(),
+            cx,
+        )
+    }
+
+    pub fn new_with_options(
+        source: SharedString,
+        language_registry: Option<Arc<LanguageRegistry>>,
+        fallback_code_block_language: Option<LanguageName>,
+        options: MarkdownOptions,
+        cx: &mut Context<Self>,
     ) -> Self {
         let focus_handle = cx.focus_handle();
         let mut this = Self {
@@ -306,6 +337,7 @@ impl Markdown {
             selection: Selection::default(),
             pressed_link: None,
             autoscroll_request: None,
+            active_root_block: None,
             should_reparse: false,
             images_by_source_offset: Default::default(),
             parsed_markdown: ParsedMarkdown::default(),
@@ -313,9 +345,8 @@ impl Markdown {
             focus_handle,
             language_registry,
             fallback_code_block_language,
-            options: Options {
-                parse_links_only: false,
-            },
+            options,
+            mermaid_state: MermaidState::default(),
             copied_code_blocks: HashSet::default(),
             code_block_scroll_handles: BTreeMap::default(),
             context_menu_selected_text: None,
@@ -325,28 +356,16 @@ impl Markdown {
     }
 
     pub fn new_text(source: SharedString, cx: &mut Context<Self>) -> Self {
-        let focus_handle = cx.focus_handle();
-        let mut this = Self {
+        Self::new_with_options(
             source,
-            selection: Selection::default(),
-            pressed_link: None,
-            autoscroll_request: None,
-            should_reparse: false,
-            parsed_markdown: ParsedMarkdown::default(),
-            images_by_source_offset: Default::default(),
-            pending_parse: None,
-            focus_handle,
-            language_registry: None,
-            fallback_code_block_language: None,
-            options: Options {
+            None,
+            None,
+            MarkdownOptions {
                 parse_links_only: true,
+                ..Default::default()
             },
-            copied_code_blocks: HashSet::default(),
-            code_block_scroll_handles: BTreeMap::default(),
-            context_menu_selected_text: None,
-        };
-        this.parse(cx);
-        this
+            cx,
+        )
     }
 
     fn code_block_scroll_handle(&mut self, id: usize) -> ScrollHandle {
@@ -409,6 +428,30 @@ impl Markdown {
         self.parse(cx);
     }
 
+    pub fn request_autoscroll_to_source_index(
+        &mut self,
+        source_index: usize,
+        cx: &mut Context<Self>,
+    ) {
+        self.autoscroll_request = Some(source_index);
+        cx.refresh_windows();
+    }
+
+    pub fn set_active_root_for_source_index(
+        &mut self,
+        source_index: Option<usize>,
+        cx: &mut Context<Self>,
+    ) {
+        let active_root_block =
+            source_index.and_then(|index| self.parsed_markdown.root_block_for_source_index(index));
+        if self.active_root_block == active_root_block {
+            return;
+        }
+
+        self.active_root_block = active_root_block;
+        cx.notify();
+    }
+
     pub fn reset(&mut self, source: SharedString, cx: &mut Context<Self>) {
         if source == self.source() {
             return;
@@ -488,6 +531,17 @@ impl Markdown {
 
     fn parse(&mut self, cx: &mut Context<Self>) {
         if self.source.is_empty() {
+            self.should_reparse = false;
+            self.pending_parse.take();
+            self.parsed_markdown = ParsedMarkdown {
+                source: self.source.clone(),
+                ..Default::default()
+            };
+            self.active_root_block = None;
+            self.images_by_source_offset.clear();
+            self.mermaid_state.clear();
+            cx.notify();
+            cx.refresh_windows();
             return;
         }
 
@@ -502,6 +556,8 @@ impl Markdown {
     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 should_parse_html = self.options.parse_html;
+        let should_render_mermaid_diagrams = self.options.render_mermaid_diagrams;
         let language_registry = self.language_registry.clone();
         let fallback = self.fallback_code_block_language.clone();
 
@@ -513,12 +569,25 @@ impl Markdown {
                         source,
                         languages_by_name: TreeMap::default(),
                         languages_by_path: TreeMap::default(),
+                        root_block_starts: Arc::default(),
+                        html_blocks: BTreeMap::default(),
+                        mermaid_diagrams: BTreeMap::default(),
                     },
                     Default::default(),
                 );
             }
 
-            let (events, language_names, paths) = parse_markdown(&source);
+            let parsed = parse_markdown_with_options(&source, should_parse_html);
+            let events = parsed.events;
+            let language_names = parsed.language_names;
+            let paths = parsed.language_paths;
+            let root_block_starts = parsed.root_block_starts;
+            let html_blocks = parsed.html_blocks;
+            let mermaid_diagrams = if should_render_mermaid_diagrams {
+                extract_mermaid_diagrams(&source, &events)
+            } else {
+                BTreeMap::default()
+            };
             let mut images_by_source_offset = HashMap::default();
             let mut languages_by_name = TreeMap::default();
             let mut languages_by_path = TreeMap::default();
@@ -577,6 +646,9 @@ impl Markdown {
                     events: Arc::from(events),
                     languages_by_name,
                     languages_by_path,
+                    root_block_starts: Arc::from(root_block_starts),
+                    html_blocks,
+                    mermaid_diagrams,
                 },
                 images_by_source_offset,
             )
@@ -588,10 +660,22 @@ impl Markdown {
             this.update(cx, |this, cx| {
                 this.parsed_markdown = parsed;
                 this.images_by_source_offset = images_by_source_offset;
+                if this.active_root_block.is_some_and(|block_index| {
+                    block_index >= this.parsed_markdown.root_block_starts.len()
+                }) {
+                    this.active_root_block = None;
+                }
+                if this.options.render_mermaid_diagrams {
+                    let parsed_markdown = this.parsed_markdown.clone();
+                    this.mermaid_state.update(&parsed_markdown, cx);
+                } else {
+                    this.mermaid_state.clear();
+                }
                 this.pending_parse.take();
                 if this.should_reparse {
                     this.parse(cx);
                 }
+                cx.notify();
                 cx.refresh_windows();
             })
             .ok();
@@ -685,6 +769,9 @@ pub struct ParsedMarkdown {
     pub events: Arc<[(Range<usize>, MarkdownEvent)]>,
     pub languages_by_name: TreeMap<SharedString, Arc<Language>>,
     pub languages_by_path: TreeMap<Arc<str>, Arc<Language>>,
+    pub root_block_starts: Arc<[usize]>,
+    pub(crate) html_blocks: BTreeMap<usize, html::html_parser::ParsedHtmlBlock>,
+    pub(crate) mermaid_diagrams: BTreeMap<usize, ParsedMarkdownMermaidDiagram>,
 }
 
 impl ParsedMarkdown {
@@ -695,6 +782,30 @@ impl ParsedMarkdown {
     pub fn events(&self) -> &Arc<[(Range<usize>, MarkdownEvent)]> {
         &self.events
     }
+
+    pub fn root_block_starts(&self) -> &Arc<[usize]> {
+        &self.root_block_starts
+    }
+
+    pub fn root_block_for_source_index(&self, source_index: usize) -> Option<usize> {
+        if self.root_block_starts.is_empty() {
+            return None;
+        }
+
+        let partition = self
+            .root_block_starts
+            .partition_point(|block_start| *block_start <= source_index);
+
+        Some(partition.saturating_sub(1))
+    }
+}
+
+pub enum AutoscrollBehavior {
+    /// Propagate the request up the element tree for the nearest
+    /// scrollable ancestor (e.g. `List`) to handle.
+    Propagate,
+    /// Directly control a specific scroll handle.
+    Controlled(ScrollHandle),
 }
 
 pub struct MarkdownElement {
@@ -702,6 +813,11 @@ pub struct MarkdownElement {
     style: MarkdownStyle,
     code_block_renderer: CodeBlockRenderer,
     on_url_click: Option<Box<dyn Fn(SharedString, &mut Window, &mut App)>>,
+    on_source_click: Option<SourceClickCallback>,
+    on_checkbox_toggle: Option<CheckboxToggleCallback>,
+    image_resolver: Option<Box<dyn Fn(&str) -> Option<ImageSource>>>,
+    show_root_block_markers: bool,
+    autoscroll: AutoscrollBehavior,
 }
 
 impl MarkdownElement {
@@ -715,6 +831,11 @@ impl MarkdownElement {
                 border: false,
             },
             on_url_click: None,
+            on_source_click: None,
+            on_checkbox_toggle: None,
+            image_resolver: None,
+            show_root_block_markers: false,
+            autoscroll: AutoscrollBehavior::Propagate,
         }
     }
 
@@ -752,6 +873,147 @@ impl MarkdownElement {
         self
     }
 
+    pub fn on_source_click(
+        mut self,
+        handler: impl Fn(usize, usize, &mut Window, &mut App) -> bool + 'static,
+    ) -> Self {
+        self.on_source_click = Some(Box::new(handler));
+        self
+    }
+
+    pub fn on_checkbox_toggle(
+        mut self,
+        handler: impl Fn(Range<usize>, bool, &mut Window, &mut App) + 'static,
+    ) -> Self {
+        self.on_checkbox_toggle = Some(Rc::new(handler));
+        self
+    }
+
+    pub fn image_resolver(
+        mut self,
+        resolver: impl Fn(&str) -> Option<ImageSource> + 'static,
+    ) -> Self {
+        self.image_resolver = Some(Box::new(resolver));
+        self
+    }
+
+    pub fn show_root_block_markers(mut self) -> Self {
+        self.show_root_block_markers = true;
+        self
+    }
+
+    pub fn scroll_handle(mut self, scroll_handle: ScrollHandle) -> Self {
+        self.autoscroll = AutoscrollBehavior::Controlled(scroll_handle);
+        self
+    }
+
+    fn push_markdown_image(
+        &self,
+        builder: &mut MarkdownElementBuilder,
+        range: &Range<usize>,
+        source: ImageSource,
+        width: Option<DefiniteLength>,
+        height: Option<DefiniteLength>,
+    ) {
+        builder.modify_current_div(|el| {
+            el.items_center().flex().flex_row().child(
+                img(source)
+                    .max_w_full()
+                    .when_some(height, |this, height| this.h(height))
+                    .when_some(width, |this, width| this.w(width)),
+            )
+        });
+        let _ = range;
+    }
+
+    fn push_markdown_paragraph(
+        &self,
+        builder: &mut MarkdownElementBuilder,
+        range: &Range<usize>,
+        markdown_end: usize,
+    ) {
+        builder.push_div(
+            div().when(!self.style.height_is_multiple_of_line_height, |el| {
+                el.mb_2().line_height(rems(1.3))
+            }),
+            range,
+            markdown_end,
+        );
+    }
+
+    fn push_markdown_heading(
+        &self,
+        builder: &mut MarkdownElementBuilder,
+        level: pulldown_cmark::HeadingLevel,
+        range: &Range<usize>,
+        markdown_end: usize,
+    ) {
+        let mut heading = div().mb_2();
+        heading = apply_heading_style(heading, level, self.style.heading_level_styles.as_ref());
+
+        let mut heading_style = self.style.heading.clone();
+        let heading_text_style = heading_style.text_style().clone();
+        heading.style().refine(&heading_style);
+
+        builder.push_text_style(heading_text_style);
+        builder.push_div(heading, range, markdown_end);
+    }
+
+    fn pop_markdown_heading(&self, builder: &mut MarkdownElementBuilder) {
+        builder.pop_div();
+        builder.pop_text_style();
+    }
+
+    fn push_markdown_block_quote(
+        &self,
+        builder: &mut MarkdownElementBuilder,
+        range: &Range<usize>,
+        markdown_end: usize,
+    ) {
+        builder.push_text_style(self.style.block_quote.clone());
+        builder.push_div(
+            div()
+                .pl_4()
+                .mb_2()
+                .border_l_4()
+                .border_color(self.style.block_quote_border_color),
+            range,
+            markdown_end,
+        );
+    }
+
+    fn pop_markdown_block_quote(&self, builder: &mut MarkdownElementBuilder) {
+        builder.pop_div();
+        builder.pop_text_style();
+    }
+
+    fn push_markdown_list_item(
+        &self,
+        builder: &mut MarkdownElementBuilder,
+        bullet: AnyElement,
+        range: &Range<usize>,
+        markdown_end: usize,
+    ) {
+        builder.push_div(
+            div()
+                .when(!self.style.height_is_multiple_of_line_height, |el| {
+                    el.mb_1().gap_1().line_height(rems(1.3))
+                })
+                .h_flex()
+                .items_start()
+                .child(bullet),
+            range,
+            markdown_end,
+        );
+        // Without `w_0`, text doesn't wrap to the width of the container.
+        builder.push_div(div().flex_1().w_0(), range, markdown_end);
+    }
+
+    fn pop_markdown_list_item(&self, builder: &mut MarkdownElementBuilder) {
+        builder.pop_div();
+        builder.pop_div();
+    }
+
     fn paint_selection(
         &self,
         bounds: Bounds<Pixels>,
@@ -845,6 +1107,7 @@ impl MarkdownElement {
         }
 
         let on_open_url = self.on_url_click.take();
+        let on_source_click = self.on_source_click.take();
 
         self.on_mouse_event(window, cx, {
             let hitbox = hitbox.clone();
@@ -872,6 +1135,16 @@ impl MarkdownElement {
                                 match rendered_text.source_index_for_position(event.position) {
                                     Ok(ix) | Err(ix) => ix,
                                 };
+                            if let Some(handler) = on_source_click.as_ref() {
+                                let blocked = handler(source_index, event.click_count, window, cx);
+                                if blocked {
+                                    markdown.selection = Selection::default();
+                                    markdown.pressed_link = None;
+                                    window.prevent_default();
+                                    cx.notify();
+                                    return;
+                                }
+                            }
                             let (range, mode) = match event.click_count {
                                 1 => {
                                     let range = source_index..source_index;
@@ -979,14 +1252,38 @@ impl MarkdownElement {
             .update(cx, |markdown, _| markdown.autoscroll_request.take())?;
         let (position, line_height) = rendered_text.position_for_source_index(autoscroll_index)?;
 
-        let text_style = self.style.base_text_style.clone();
-        let font_id = window.text_system().resolve_font(&text_style.font());
-        let font_size = text_style.font_size.to_pixels(window.rem_size());
-        let em_width = window.text_system().em_width(font_id, font_size).unwrap();
-        window.request_autoscroll(Bounds::from_corners(
-            point(position.x - 3. * em_width, position.y - 3. * line_height),
-            point(position.x + 3. * em_width, position.y + 3. * line_height),
-        ));
+        match &self.autoscroll {
+            AutoscrollBehavior::Controlled(scroll_handle) => {
+                let viewport = scroll_handle.bounds();
+                let margin = line_height * 3.;
+                let top_goal = viewport.top() + margin;
+                let bottom_goal = viewport.bottom() - margin;
+                let current_offset = scroll_handle.offset();
+
+                let new_offset_y = if position.y < top_goal {
+                    current_offset.y + (top_goal - position.y)
+                } else if position.y + line_height > bottom_goal {
+                    current_offset.y + (bottom_goal - (position.y + line_height))
+                } else {
+                    current_offset.y
+                };
+
+                scroll_handle.set_offset(point(
+                    current_offset.x,
+                    new_offset_y.clamp(-scroll_handle.max_offset().y, Pixels::ZERO),
+                ));
+            }
+            AutoscrollBehavior::Propagate => {
+                let text_style = self.style.base_text_style.clone();
+                let font_id = window.text_system().resolve_font(&text_style.font());
+                let font_size = text_style.font_size.to_pixels(window.rem_size());
+                let em_width = window.text_system().em_width(font_id, font_size).unwrap();
+                window.request_autoscroll(Bounds::from_corners(
+                    point(position.x - 3. * em_width, position.y - 3. * line_height),
+                    point(position.x + 3. * em_width, position.y + 3. * line_height),
+                ));
+            }
+        }
         Some(())
     }
 
@@ -1038,11 +1335,14 @@ impl Element for MarkdownElement {
             self.style.base_text_style.clone(),
             self.style.syntax.clone(),
         );
-        let (parsed_markdown, images) = {
+        let (parsed_markdown, images, active_root_block, render_mermaid_diagrams, mermaid_state) = {
             let markdown = self.markdown.read(cx);
             (
                 markdown.parsed_markdown.clone(),
                 markdown.images_by_source_offset.clone(),
+                markdown.active_root_block,
+                markdown.options.render_mermaid_diagrams,
+                markdown.mermaid_state.clone(),
             )
         };
         let markdown_end = if let Some(last) = parsed_markdown.events.last() {
@@ -1053,6 +1353,8 @@ impl Element for MarkdownElement {
         let mut code_block_ids = HashSet::default();
 
         let mut current_img_block_range: Option<Range<usize>> = None;
+        let mut handled_html_block = false;
+        let mut rendered_mermaid_block = false;
         for (index, (range, event)) in parsed_markdown.events.iter().enumerate() {
             // Skip alt text for images that rendered
             if let Some(current_img_block_range) = &current_img_block_range
@@ -1061,58 +1363,83 @@ impl Element for MarkdownElement {
                 continue;
             }
 
+            if handled_html_block {
+                if let MarkdownEvent::End(MarkdownTagEnd::HtmlBlock) = event {
+                    handled_html_block = false;
+                } else {
+                    continue;
+                }
+            }
+
+            if rendered_mermaid_block {
+                if matches!(event, MarkdownEvent::End(MarkdownTagEnd::CodeBlock)) {
+                    rendered_mermaid_block = false;
+                }
+                continue;
+            }
+
             match event {
+                MarkdownEvent::RootStart => {
+                    if self.show_root_block_markers {
+                        builder.push_root_block(range, markdown_end);
+                    }
+                }
+                MarkdownEvent::RootEnd(root_block_index) => {
+                    if self.show_root_block_markers {
+                        builder.pop_root_block(
+                            active_root_block == Some(*root_block_index),
+                            cx.theme().colors().border,
+                            cx.theme().colors().border_variant,
+                        );
+                    }
+                }
                 MarkdownEvent::Start(tag) => {
                     match tag {
-                        MarkdownTag::Image { .. } => {
+                        MarkdownTag::Image { dest_url, .. } => {
                             if let Some(image) = images.get(&range.start) {
                                 current_img_block_range = Some(range.clone());
-                                builder.modify_current_div(|el| {
-                                    el.items_center()
-                                        .flex()
-                                        .flex_row()
-                                        .child(img(image.clone()))
-                                });
+                                self.push_markdown_image(
+                                    &mut builder,
+                                    range,
+                                    image.clone().into(),
+                                    None,
+                                    None,
+                                );
+                            } else if let Some(source) = self
+                                .image_resolver
+                                .as_ref()
+                                .and_then(|resolve| resolve(dest_url.as_ref()))
+                            {
+                                current_img_block_range = Some(range.clone());
+                                self.push_markdown_image(&mut builder, range, source, None, None);
                             }
                         }
                         MarkdownTag::Paragraph => {
-                            builder.push_div(
-                                div().when(!self.style.height_is_multiple_of_line_height, |el| {
-                                    el.mb_2().line_height(rems(1.3))
-                                }),
-                                range,
-                                markdown_end,
-                            );
+                            self.push_markdown_paragraph(&mut builder, range, markdown_end);
                         }
                         MarkdownTag::Heading { level, .. } => {
-                            let mut heading = div().mb_2();
-
-                            heading = apply_heading_style(
-                                heading,
-                                *level,
-                                self.style.heading_level_styles.as_ref(),
-                            );
-
-                            heading.style().refine(&self.style.heading);
-
-                            let text_style = self.style.heading.text_style().clone();
-
-                            builder.push_text_style(text_style);
-                            builder.push_div(heading, range, markdown_end);
+                            self.push_markdown_heading(&mut builder, *level, range, markdown_end);
                         }
                         MarkdownTag::BlockQuote => {
-                            builder.push_text_style(self.style.block_quote.clone());
-                            builder.push_div(
-                                div()
-                                    .pl_4()
-                                    .mb_2()
-                                    .border_l_4()
-                                    .border_color(self.style.block_quote_border_color),
-                                range,
-                                markdown_end,
-                            );
+                            self.push_markdown_block_quote(&mut builder, range, markdown_end);
                         }
                         MarkdownTag::CodeBlock { kind, .. } => {
+                            if render_mermaid_diagrams
+                                && let Some(mermaid_diagram) =
+                                    parsed_markdown.mermaid_diagrams.get(&range.start)
+                            {
+                                builder.push_sourced_element(
+                                    mermaid_diagram.content_range.clone(),
+                                    render_mermaid_diagram(
+                                        mermaid_diagram,
+                                        &mermaid_state,
+                                        &self.style,
+                                    ),
+                                );
+                                rendered_mermaid_block = true;
+                                continue;
+                            }
+
                             let language = match kind {
                                 CodeBlockKind::Fenced => None,
                                 CodeBlockKind::FencedLang(language) => {
@@ -1196,46 +1523,57 @@ impl Element for MarkdownElement {
                                 (CodeBlockRenderer::Custom { .. }, _) => {}
                             }
                         }
-                        MarkdownTag::HtmlBlock => builder.push_div(div(), range, markdown_end),
+                        MarkdownTag::HtmlBlock => {
+                            builder.push_div(div(), range, markdown_end);
+                            if let Some(block) = parsed_markdown.html_blocks.get(&range.start) {
+                                self.render_html_block(block, &mut builder, markdown_end, cx);
+                                handled_html_block = true;
+                            }
+                        }
                         MarkdownTag::List(bullet_index) => {
                             builder.push_list(*bullet_index);
                             builder.push_div(div().pl_2p5(), range, markdown_end);
                         }
                         MarkdownTag::Item => {
-                            let bullet = if let Some((_, MarkdownEvent::TaskListMarker(checked))) =
-                                parsed_markdown.events.get(index.saturating_add(1))
-                            {
-                                let source = &parsed_markdown.source()[range.clone()];
-
-                                Checkbox::new(
-                                    ElementId::Name(source.to_string().into()),
-                                    if *checked {
+                            let bullet =
+                                if let Some((task_range, MarkdownEvent::TaskListMarker(checked))) =
+                                    parsed_markdown.events.get(index.saturating_add(1))
+                                {
+                                    let source = &parsed_markdown.source()[range.clone()];
+                                    let checked = *checked;
+                                    let toggle_state = if checked {
                                         ToggleState::Selected
                                     } else {
                                         ToggleState::Unselected
-                                    },
-                                )
-                                .fill()
-                                .visualization_only(true)
-                                .into_any_element()
-                            } else if let Some(bullet_index) = builder.next_bullet_index() {
-                                div().child(format!("{}.", bullet_index)).into_any_element()
-                            } else {
-                                div().child("β€’").into_any_element()
-                            };
-                            builder.push_div(
-                                div()
-                                    .when(!self.style.height_is_multiple_of_line_height, |el| {
-                                        el.mb_1().gap_1().line_height(rems(1.3))
-                                    })
-                                    .h_flex()
-                                    .items_start()
-                                    .child(bullet),
-                                range,
-                                markdown_end,
-                            );
-                            // Without `w_0`, text doesn't wrap to the width of the container.
-                            builder.push_div(div().flex_1().w_0(), range, markdown_end);
+                                    };
+
+                                    let checkbox = Checkbox::new(
+                                        ElementId::Name(source.to_string().into()),
+                                        toggle_state,
+                                    )
+                                    .fill();
+
+                                    if let Some(on_toggle) = self.on_checkbox_toggle.clone() {
+                                        let task_source_range = task_range.clone();
+                                        checkbox
+                                            .on_click(move |_state, window, cx| {
+                                                on_toggle(
+                                                    task_source_range.clone(),
+                                                    !checked,
+                                                    window,
+                                                    cx,
+                                                );
+                                            })
+                                            .into_any_element()
+                                    } else {
+                                        checkbox.visualization_only(true).into_any_element()
+                                    }
+                                } else if let Some(bullet_index) = builder.next_bullet_index() {
+                                    div().child(format!("{}.", bullet_index)).into_any_element()
+                                } else {
+                                    div().child("β€’").into_any_element()
+                                };
+                            self.push_markdown_list_item(&mut builder, bullet, range, markdown_end);
                         }
                         MarkdownTag::Emphasis => builder.push_text_style(TextStyleRefinement {
                             font_style: Some(FontStyle::Italic),
@@ -1340,12 +1678,10 @@ impl Element for MarkdownElement {
                         builder.pop_div();
                     }
                     MarkdownTagEnd::Heading(_) => {
-                        builder.pop_div();
-                        builder.pop_text_style()
+                        self.pop_markdown_heading(&mut builder);
                     }
                     MarkdownTagEnd::BlockQuote(_kind) => {
-                        builder.pop_text_style();
-                        builder.pop_div()
+                        self.pop_markdown_block_quote(&mut builder);
                     }
                     MarkdownTagEnd::CodeBlock => {
                         builder.trim_trailing_newline();
@@ -1423,8 +1759,7 @@ impl Element for MarkdownElement {
                         builder.pop_div();
                     }
                     MarkdownTagEnd::Item => {
-                        builder.pop_div();
-                        builder.pop_div();
+                        self.pop_markdown_list_item(&mut builder);
                     }
                     MarkdownTagEnd::Emphasis => builder.pop_text_style(),
                     MarkdownTagEnd::Strong => builder.pop_text_style(),
@@ -1842,6 +2177,15 @@ impl MarkdownElementBuilder {
         self.div_stack.push(div);
     }
 
+    fn push_root_block(&mut self, range: &Range<usize>, markdown_end: usize) {
+        self.push_div(
+            div().group("markdown-root-block").relative(),
+            range,
+            markdown_end,
+        );
+        self.push_div(div().pl_4(), range, markdown_end);
+    }
+
     fn modify_current_div(&mut self, f: impl FnOnce(AnyDiv) -> AnyDiv) {
         self.flush_text();
         if let Some(div) = self.div_stack.pop() {
@@ -1849,12 +2193,53 @@ impl MarkdownElementBuilder {
         }
     }
 
+    fn pop_root_block(
+        &mut self,
+        is_active: bool,
+        active_gutter_color: Hsla,
+        hovered_gutter_color: Hsla,
+    ) {
+        self.pop_div();
+        self.modify_current_div(|el| {
+            el.child(
+                div()
+                    .h_full()
+                    .w(px(4.0))
+                    .when(is_active, |this| this.bg(active_gutter_color))
+                    .group_hover("markdown-root-block", |this| {
+                        if is_active {
+                            this
+                        } else {
+                            this.bg(hovered_gutter_color)
+                        }
+                    })
+                    .rounded_xs()
+                    .absolute()
+                    .left_0()
+                    .top_0(),
+            )
+        });
+        self.pop_div();
+    }
+
     fn pop_div(&mut self) {
         self.flush_text();
         let div = self.div_stack.pop().unwrap().into_any_element();
         self.div_stack.last_mut().unwrap().extend(iter::once(div));
     }
 
+    fn push_sourced_element(&mut self, source_range: Range<usize>, element: impl Into<AnyElement>) {
+        self.flush_text();
+        let anchor = self.render_source_anchor(source_range);
+        self.div_stack.last_mut().unwrap().extend([{
+            div()
+                .relative()
+                .child(anchor)
+                .child(element.into())
+                .into_any_element()
+        }]);
+    }
+
     fn push_list(&mut self, bullet_index: Option<u64>) {
         self.list_stack.push(ListStackEntry { bullet_index });
     }
@@ -1904,9 +2289,10 @@ impl MarkdownElementBuilder {
                 }
 
                 let mut run_style = self.text_style();
-                if let Some(highlight) = highlight_id.style(&self.syntax_theme) {
+                if let Some(highlight) = self.syntax_theme.get(highlight_id).cloned() {
                     run_style = run_style.highlight(highlight);
                 }
+
                 self.pending_line.runs.push(run_style.to_run(range.len()));
                 offset = range.end;
             }
@@ -1955,6 +2341,29 @@ impl MarkdownElementBuilder {
         }
     }
 
+    fn render_source_anchor(&mut self, source_range: Range<usize>) -> AnyElement {
+        let mut text_style = self.base_text_style.clone();
+        text_style.color = Hsla::transparent_black();
+        let text = "\u{200B}";
+        let styled_text = StyledText::new(text).with_runs(vec![text_style.to_run(text.len())]);
+        self.rendered_lines.push(RenderedLine {
+            layout: styled_text.layout().clone(),
+            source_mappings: vec![SourceMapping {
+                rendered_index: 0,
+                source_index: source_range.start,
+            }],
+            source_end: source_range.end,
+            language: None,
+        });
+        div()
+            .absolute()
+            .top_0()
+            .left_0()
+            .opacity(0.)
+            .child(styled_text)
+            .into_any_element()
+    }
+
     fn flush_text(&mut self) {
         let line = mem::take(&mut self.pending_line);
         if line.text.is_empty() {
@@ -2004,7 +2413,7 @@ impl RenderedLine {
             Ok(ix) => &self.source_mappings[ix],
             Err(ix) => &self.source_mappings[ix - 1],
         };
-        mapping.rendered_index + (source_index - mapping.source_index)
+        (mapping.rendered_index + (source_index - mapping.source_index)).min(self.layout.len())
     }
 
     fn source_index_for_rendered_index(&self, rendered_index: usize) -> usize {
@@ -2332,6 +2741,15 @@ mod tests {
         markdown: &str,
         language_registry: Option<Arc<LanguageRegistry>>,
         cx: &mut TestAppContext,
+    ) -> RenderedText {
+        render_markdown_with_options(markdown, language_registry, MarkdownOptions::default(), cx)
+    }
+
+    fn render_markdown_with_options(
+        markdown: &str,
+        language_registry: Option<Arc<LanguageRegistry>>,
+        options: MarkdownOptions,
+        cx: &mut TestAppContext,
     ) -> RenderedText {
         struct TestWindow;
 
@@ -2344,8 +2762,15 @@ mod tests {
         ensure_theme_initialized(cx);
 
         let (_, cx) = cx.add_window_view(|_, _| TestWindow);
-        let markdown =
-            cx.new(|cx| Markdown::new(markdown.to_string().into(), language_registry, None, cx));
+        let markdown = cx.new(|cx| {
+            Markdown::new_with_options(
+                markdown.to_string().into(),
+                language_registry,
+                None,
+                options,
+                cx,
+            )
+        });
         cx.run_until_parked();
         let (rendered, _) = cx.draw(
             Default::default(),
@@ -2525,7 +2950,7 @@ mod tests {
     #[test]
     fn test_table_checkbox_detection() {
         let md = "| Done |\n|------|\n| [x] |\n| [ ] |";
-        let (events, _, _) = crate::parser::parse_markdown(md);
+        let events = crate::parser::parse_markdown_with_options(md, false).events;
 
         let mut in_table = false;
         let mut cell_texts: Vec<String> = Vec::new();

crates/markdown/src/mermaid.rs πŸ”—

@@ -0,0 +1,614 @@
+use collections::HashMap;
+use gpui::{
+    Animation, AnimationExt, AnyElement, Context, ImageSource, RenderImage, StyledText, Task, img,
+    pulsating_between,
+};
+use std::collections::BTreeMap;
+use std::ops::Range;
+use std::sync::{Arc, OnceLock};
+use std::time::Duration;
+use ui::prelude::*;
+
+use crate::parser::{CodeBlockKind, MarkdownEvent, MarkdownTag};
+
+use super::{Markdown, MarkdownStyle, ParsedMarkdown};
+
+type MermaidDiagramCache = HashMap<ParsedMarkdownMermaidDiagramContents, Arc<CachedMermaidDiagram>>;
+
+#[derive(Clone, Debug)]
+pub(crate) struct ParsedMarkdownMermaidDiagram {
+    pub(crate) content_range: Range<usize>,
+    pub(crate) contents: ParsedMarkdownMermaidDiagramContents,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+pub(crate) struct ParsedMarkdownMermaidDiagramContents {
+    pub(crate) contents: SharedString,
+    pub(crate) scale: u32,
+}
+
+#[derive(Default, Clone)]
+pub(crate) struct MermaidState {
+    cache: MermaidDiagramCache,
+    order: Vec<ParsedMarkdownMermaidDiagramContents>,
+}
+
+struct CachedMermaidDiagram {
+    render_image: Arc<OnceLock<anyhow::Result<Arc<RenderImage>>>>,
+    fallback_image: Option<Arc<RenderImage>>,
+    _task: Task<()>,
+}
+
+impl MermaidState {
+    pub(crate) fn clear(&mut self) {
+        self.cache.clear();
+        self.order.clear();
+    }
+
+    fn get_fallback_image(
+        idx: usize,
+        old_order: &[ParsedMarkdownMermaidDiagramContents],
+        new_order_len: usize,
+        cache: &MermaidDiagramCache,
+    ) -> Option<Arc<RenderImage>> {
+        if old_order.len() != new_order_len {
+            return None;
+        }
+
+        old_order.get(idx).and_then(|old_content| {
+            cache.get(old_content).and_then(|old_cached| {
+                old_cached
+                    .render_image
+                    .get()
+                    .and_then(|result| result.as_ref().ok().cloned())
+                    .or_else(|| old_cached.fallback_image.clone())
+            })
+        })
+    }
+
+    pub(crate) fn update(&mut self, parsed: &ParsedMarkdown, cx: &mut Context<Markdown>) {
+        let mut new_order = Vec::new();
+        for mermaid_diagram in parsed.mermaid_diagrams.values() {
+            new_order.push(mermaid_diagram.contents.clone());
+        }
+
+        for (idx, new_content) in new_order.iter().enumerate() {
+            if !self.cache.contains_key(new_content) {
+                let fallback =
+                    Self::get_fallback_image(idx, &self.order, new_order.len(), &self.cache);
+                self.cache.insert(
+                    new_content.clone(),
+                    Arc::new(CachedMermaidDiagram::new(new_content.clone(), fallback, cx)),
+                );
+            }
+        }
+
+        let new_order_set: std::collections::HashSet<_> = new_order.iter().cloned().collect();
+        self.cache
+            .retain(|content, _| new_order_set.contains(content));
+        self.order = new_order;
+    }
+}
+
+impl CachedMermaidDiagram {
+    fn new(
+        contents: ParsedMarkdownMermaidDiagramContents,
+        fallback_image: Option<Arc<RenderImage>>,
+        cx: &mut Context<Markdown>,
+    ) -> Self {
+        let render_image = Arc::new(OnceLock::<anyhow::Result<Arc<RenderImage>>>::new());
+        let render_image_clone = render_image.clone();
+        let svg_renderer = cx.svg_renderer();
+
+        let task = cx.spawn(async move |this, cx| {
+            let value = cx
+                .background_spawn(async move {
+                    let svg_string = mermaid_rs_renderer::render(&contents.contents)?;
+                    let scale = contents.scale as f32 / 100.0;
+                    svg_renderer
+                        .render_single_frame(svg_string.as_bytes(), scale, true)
+                        .map_err(|error| anyhow::anyhow!("{error}"))
+                })
+                .await;
+            let _ = render_image_clone.set(value);
+            this.update(cx, |_, cx| {
+                cx.notify();
+            })
+            .ok();
+        });
+
+        Self {
+            render_image,
+            fallback_image,
+            _task: task,
+        }
+    }
+
+    #[cfg(test)]
+    fn new_for_test(
+        render_image: Option<Arc<RenderImage>>,
+        fallback_image: Option<Arc<RenderImage>>,
+    ) -> Self {
+        let result = Arc::new(OnceLock::new());
+        if let Some(render_image) = render_image {
+            let _ = result.set(Ok(render_image));
+        }
+        Self {
+            render_image: result,
+            fallback_image,
+            _task: Task::ready(()),
+        }
+    }
+}
+
+fn parse_mermaid_info(info: &str) -> Option<u32> {
+    let mut parts = info.split_whitespace();
+    if parts.next()? != "mermaid" {
+        return None;
+    }
+
+    Some(
+        parts
+            .next()
+            .and_then(|scale| scale.parse().ok())
+            .unwrap_or(100)
+            .clamp(10, 500),
+    )
+}
+
+pub(crate) fn extract_mermaid_diagrams(
+    source: &str,
+    events: &[(Range<usize>, MarkdownEvent)],
+) -> BTreeMap<usize, ParsedMarkdownMermaidDiagram> {
+    let mut mermaid_diagrams = BTreeMap::default();
+
+    for (source_range, event) in events {
+        let MarkdownEvent::Start(MarkdownTag::CodeBlock { kind, metadata }) = event else {
+            continue;
+        };
+        let CodeBlockKind::FencedLang(info) = kind else {
+            continue;
+        };
+        let Some(scale) = parse_mermaid_info(info.as_ref()) else {
+            continue;
+        };
+
+        let contents = source[metadata.content_range.clone()]
+            .strip_suffix('\n')
+            .unwrap_or(&source[metadata.content_range.clone()])
+            .to_string();
+        mermaid_diagrams.insert(
+            source_range.start,
+            ParsedMarkdownMermaidDiagram {
+                content_range: metadata.content_range.clone(),
+                contents: ParsedMarkdownMermaidDiagramContents {
+                    contents: contents.into(),
+                    scale,
+                },
+            },
+        );
+    }
+
+    mermaid_diagrams
+}
+
+pub(crate) fn render_mermaid_diagram(
+    parsed: &ParsedMarkdownMermaidDiagram,
+    mermaid_state: &MermaidState,
+    style: &MarkdownStyle,
+) -> AnyElement {
+    let cached = mermaid_state.cache.get(&parsed.contents);
+    let mut container = div().w_full();
+    container.style().refine(&style.code_block);
+
+    if let Some(result) = cached.and_then(|cached| cached.render_image.get()) {
+        match result {
+            Ok(render_image) => container
+                .child(
+                    div().w_full().child(
+                        img(ImageSource::Render(render_image.clone()))
+                            .max_w_full()
+                            .with_fallback(|| {
+                                div()
+                                    .child(Label::new("Failed to load mermaid diagram"))
+                                    .into_any_element()
+                            }),
+                    ),
+                )
+                .into_any_element(),
+            Err(_) => container
+                .child(StyledText::new(parsed.contents.contents.clone()))
+                .into_any_element(),
+        }
+    } else if let Some(fallback) = cached.and_then(|cached| cached.fallback_image.as_ref()) {
+        container
+            .child(
+                div()
+                    .w_full()
+                    .child(
+                        img(ImageSource::Render(fallback.clone()))
+                            .max_w_full()
+                            .with_fallback(|| {
+                                div()
+                                    .child(Label::new("Failed to load mermaid diagram"))
+                                    .into_any_element()
+                            }),
+                    )
+                    .with_animation(
+                        "mermaid-fallback-pulse",
+                        Animation::new(Duration::from_secs(2))
+                            .repeat()
+                            .with_easing(pulsating_between(0.6, 1.0)),
+                        |element, delta| element.opacity(delta),
+                    ),
+            )
+            .into_any_element()
+    } else {
+        container
+            .child(
+                Label::new("Rendering mermaid diagram...")
+                    .color(Color::Muted)
+                    .with_animation(
+                        "mermaid-loading-pulse",
+                        Animation::new(Duration::from_secs(2))
+                            .repeat()
+                            .with_easing(pulsating_between(0.4, 0.8)),
+                        |label, delta| label.alpha(delta),
+                    ),
+            )
+            .into_any_element()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{
+        CachedMermaidDiagram, MermaidDiagramCache, MermaidState,
+        ParsedMarkdownMermaidDiagramContents, extract_mermaid_diagrams, parse_mermaid_info,
+    };
+    use crate::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownOptions, MarkdownStyle};
+    use collections::HashMap;
+    use gpui::{Context, IntoElement, Render, RenderImage, TestAppContext, Window, size};
+    use std::sync::Arc;
+    use ui::prelude::*;
+
+    fn ensure_theme_initialized(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            if !cx.has_global::<settings::SettingsStore>() {
+                settings::init(cx);
+            }
+            if !cx.has_global::<theme::GlobalTheme>() {
+                theme::init(theme::LoadThemes::JustBase, cx);
+            }
+        });
+    }
+
+    fn render_markdown_with_options(
+        markdown: &str,
+        options: MarkdownOptions,
+        cx: &mut TestAppContext,
+    ) -> crate::RenderedText {
+        struct TestWindow;
+
+        impl Render for TestWindow {
+            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+                div()
+            }
+        }
+
+        ensure_theme_initialized(cx);
+
+        let (_, cx) = cx.add_window_view(|_, _| TestWindow);
+        let markdown = cx.new(|cx| {
+            Markdown::new_with_options(markdown.to_string().into(), None, None, options, cx)
+        });
+        cx.run_until_parked();
+        let (rendered, _) = cx.draw(
+            Default::default(),
+            size(px(600.0), px(600.0)),
+            |_window, _cx| {
+                MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
+                    CodeBlockRenderer::Default {
+                        copy_button: false,
+                        copy_button_on_hover: false,
+                        border: false,
+                    },
+                )
+            },
+        );
+        rendered.text
+    }
+
+    fn mock_render_image(cx: &mut TestAppContext) -> Arc<RenderImage> {
+        cx.update(|cx| {
+            cx.svg_renderer()
+                .render_single_frame(
+                    br#"<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>"#,
+                    1.0,
+                    true,
+                )
+                .unwrap()
+        })
+    }
+
+    fn mermaid_contents(contents: &str) -> ParsedMarkdownMermaidDiagramContents {
+        ParsedMarkdownMermaidDiagramContents {
+            contents: contents.to_string().into(),
+            scale: 100,
+        }
+    }
+
+    fn mermaid_sequence(diagrams: &[&str]) -> Vec<ParsedMarkdownMermaidDiagramContents> {
+        diagrams
+            .iter()
+            .map(|diagram| mermaid_contents(diagram))
+            .collect()
+    }
+
+    fn mermaid_fallback(
+        new_diagram: &str,
+        new_full_order: &[ParsedMarkdownMermaidDiagramContents],
+        old_full_order: &[ParsedMarkdownMermaidDiagramContents],
+        cache: &MermaidDiagramCache,
+    ) -> Option<Arc<RenderImage>> {
+        let new_content = mermaid_contents(new_diagram);
+        let idx = new_full_order
+            .iter()
+            .position(|diagram| diagram == &new_content)?;
+        MermaidState::get_fallback_image(idx, old_full_order, new_full_order.len(), cache)
+    }
+
+    #[test]
+    fn test_parse_mermaid_info() {
+        assert_eq!(parse_mermaid_info("mermaid"), Some(100));
+        assert_eq!(parse_mermaid_info("mermaid 150"), Some(150));
+        assert_eq!(parse_mermaid_info("mermaid 5"), Some(10));
+        assert_eq!(parse_mermaid_info("mermaid 999"), Some(500));
+        assert_eq!(parse_mermaid_info("rust"), None);
+    }
+
+    #[test]
+    fn test_extract_mermaid_diagrams_parses_scale() {
+        let markdown = "```mermaid 150\ngraph TD;\n```\n\n```rust\nfn main() {}\n```";
+        let events = crate::parser::parse_markdown_with_options(markdown, false).events;
+        let diagrams = extract_mermaid_diagrams(markdown, &events);
+
+        assert_eq!(diagrams.len(), 1);
+        let diagram = diagrams.values().next().unwrap();
+        assert_eq!(diagram.contents.contents, "graph TD;");
+        assert_eq!(diagram.contents.scale, 150);
+    }
+
+    #[gpui::test]
+    fn test_mermaid_fallback_on_edit(cx: &mut TestAppContext) {
+        let old_full_order = mermaid_sequence(&["graph A", "graph B", "graph C"]);
+        let new_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]);
+
+        let svg_b = mock_render_image(cx);
+
+        let mut cache: MermaidDiagramCache = HashMap::default();
+        cache.insert(
+            mermaid_contents("graph A"),
+            Arc::new(CachedMermaidDiagram::new_for_test(
+                Some(mock_render_image(cx)),
+                None,
+            )),
+        );
+        cache.insert(
+            mermaid_contents("graph B"),
+            Arc::new(CachedMermaidDiagram::new_for_test(
+                Some(svg_b.clone()),
+                None,
+            )),
+        );
+        cache.insert(
+            mermaid_contents("graph C"),
+            Arc::new(CachedMermaidDiagram::new_for_test(
+                Some(mock_render_image(cx)),
+                None,
+            )),
+        );
+
+        let fallback =
+            mermaid_fallback("graph B modified", &new_full_order, &old_full_order, &cache);
+
+        assert_eq!(fallback.as_ref().map(|image| image.id), Some(svg_b.id));
+    }
+
+    #[gpui::test]
+    fn test_mermaid_no_fallback_on_add_in_middle(cx: &mut TestAppContext) {
+        let old_full_order = mermaid_sequence(&["graph A", "graph C"]);
+        let new_full_order = mermaid_sequence(&["graph A", "graph NEW", "graph C"]);
+
+        let mut cache: MermaidDiagramCache = HashMap::default();
+        cache.insert(
+            mermaid_contents("graph A"),
+            Arc::new(CachedMermaidDiagram::new_for_test(
+                Some(mock_render_image(cx)),
+                None,
+            )),
+        );
+        cache.insert(
+            mermaid_contents("graph C"),
+            Arc::new(CachedMermaidDiagram::new_for_test(
+                Some(mock_render_image(cx)),
+                None,
+            )),
+        );
+
+        let fallback = mermaid_fallback("graph NEW", &new_full_order, &old_full_order, &cache);
+
+        assert!(fallback.is_none());
+    }
+
+    #[gpui::test]
+    fn test_mermaid_fallback_chains_on_rapid_edits(cx: &mut TestAppContext) {
+        let old_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]);
+        let new_full_order = mermaid_sequence(&["graph A", "graph B modified again", "graph C"]);
+
+        let original_svg = mock_render_image(cx);
+
+        let mut cache: MermaidDiagramCache = HashMap::default();
+        cache.insert(
+            mermaid_contents("graph A"),
+            Arc::new(CachedMermaidDiagram::new_for_test(
+                Some(mock_render_image(cx)),
+                None,
+            )),
+        );
+        cache.insert(
+            mermaid_contents("graph B modified"),
+            Arc::new(CachedMermaidDiagram::new_for_test(
+                None,
+                Some(original_svg.clone()),
+            )),
+        );
+        cache.insert(
+            mermaid_contents("graph C"),
+            Arc::new(CachedMermaidDiagram::new_for_test(
+                Some(mock_render_image(cx)),
+                None,
+            )),
+        );
+
+        let fallback = mermaid_fallback(
+            "graph B modified again",
+            &new_full_order,
+            &old_full_order,
+            &cache,
+        );
+
+        assert_eq!(
+            fallback.as_ref().map(|image| image.id),
+            Some(original_svg.id)
+        );
+    }
+
+    #[gpui::test]
+    fn test_mermaid_fallback_with_duplicate_blocks_edit_second(cx: &mut TestAppContext) {
+        let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]);
+        let new_full_order = mermaid_sequence(&["graph A", "graph A edited", "graph B"]);
+
+        let svg_a = mock_render_image(cx);
+
+        let mut cache: MermaidDiagramCache = HashMap::default();
+        cache.insert(
+            mermaid_contents("graph A"),
+            Arc::new(CachedMermaidDiagram::new_for_test(
+                Some(svg_a.clone()),
+                None,
+            )),
+        );
+        cache.insert(
+            mermaid_contents("graph B"),
+            Arc::new(CachedMermaidDiagram::new_for_test(
+                Some(mock_render_image(cx)),
+                None,
+            )),
+        );
+
+        let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache);
+
+        assert_eq!(fallback.as_ref().map(|image| image.id), Some(svg_a.id));
+    }
+
+    #[gpui::test]
+    fn test_mermaid_rendering_replaces_code_block_text(cx: &mut TestAppContext) {
+        let rendered = render_markdown_with_options(
+            "```mermaid\ngraph TD;\n```",
+            MarkdownOptions {
+                render_mermaid_diagrams: true,
+                ..Default::default()
+            },
+            cx,
+        );
+
+        let text = rendered
+            .lines
+            .iter()
+            .map(|line| line.layout.wrapped_text())
+            .collect::<Vec<_>>()
+            .join("\n");
+
+        assert!(!text.contains("graph TD;"));
+    }
+
+    #[gpui::test]
+    fn test_mermaid_source_anchor_maps_inside_block(cx: &mut TestAppContext) {
+        struct TestWindow;
+
+        impl Render for TestWindow {
+            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+                div()
+            }
+        }
+
+        ensure_theme_initialized(cx);
+
+        let (_, cx) = cx.add_window_view(|_, _| TestWindow);
+        let markdown = cx.new(|cx| {
+            Markdown::new_with_options(
+                "```mermaid\ngraph TD;\n```".into(),
+                None,
+                None,
+                MarkdownOptions {
+                    render_mermaid_diagrams: true,
+                    ..Default::default()
+                },
+                cx,
+            )
+        });
+        cx.run_until_parked();
+        let render_image = mock_render_image(cx);
+        markdown.update(cx, |markdown, _| {
+            let contents = markdown
+                .parsed_markdown
+                .mermaid_diagrams
+                .values()
+                .next()
+                .unwrap()
+                .contents
+                .clone();
+            markdown.mermaid_state.cache.insert(
+                contents.clone(),
+                Arc::new(CachedMermaidDiagram::new_for_test(Some(render_image), None)),
+            );
+            markdown.mermaid_state.order = vec![contents];
+        });
+
+        let (rendered, _) = cx.draw(
+            Default::default(),
+            size(px(600.0), px(600.0)),
+            |_window, _cx| {
+                MarkdownElement::new(markdown.clone(), MarkdownStyle::default())
+                    .code_block_renderer(CodeBlockRenderer::Default {
+                        copy_button: false,
+                        copy_button_on_hover: false,
+                        border: false,
+                    })
+            },
+        );
+
+        let mermaid_diagram = markdown.update(cx, |markdown, _| {
+            markdown
+                .parsed_markdown
+                .mermaid_diagrams
+                .values()
+                .next()
+                .unwrap()
+                .clone()
+        });
+        assert!(
+            rendered
+                .text
+                .position_for_source_index(mermaid_diagram.content_range.start)
+                .is_some()
+        );
+        assert!(
+            rendered
+                .text
+                .position_for_source_index(mermaid_diagram.content_range.end.saturating_sub(1))
+                .is_some()
+        );
+    }
+}

crates/markdown/src/parser.rs πŸ”—

@@ -4,11 +4,11 @@ pub use pulldown_cmark::TagEnd as MarkdownTagEnd;
 use pulldown_cmark::{
     Alignment, CowStr, HeadingLevel, LinkType, MetadataBlockKind, Options, Parser,
 };
-use std::{ops::Range, sync::Arc};
+use std::{collections::BTreeMap, ops::Range, sync::Arc};
 
 use collections::HashSet;
 
-use crate::path_range::PathWithRange;
+use crate::{html, path_range::PathWithRange};
 
 pub const PARSE_OPTIONS: Options = Options::ENABLE_TABLES
     .union(Options::ENABLE_FOOTNOTES)
@@ -22,16 +22,69 @@ pub const PARSE_OPTIONS: Options = Options::ENABLE_TABLES
     .union(Options::ENABLE_SUPERSCRIPT)
     .union(Options::ENABLE_SUBSCRIPT);
 
-pub fn parse_markdown(
-    text: &str,
-) -> (
-    Vec<(Range<usize>, MarkdownEvent)>,
-    HashSet<SharedString>,
-    HashSet<Arc<str>>,
-) {
-    let mut events = Vec::new();
+#[derive(Default)]
+struct ParseState {
+    events: Vec<(Range<usize>, MarkdownEvent)>,
+    root_block_starts: Vec<usize>,
+    depth: usize,
+}
+
+#[derive(Debug, Default)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) struct ParsedMarkdownData {
+    pub events: Vec<(Range<usize>, MarkdownEvent)>,
+    pub language_names: HashSet<SharedString>,
+    pub language_paths: HashSet<Arc<str>>,
+    pub root_block_starts: Vec<usize>,
+    pub html_blocks: BTreeMap<usize, html::html_parser::ParsedHtmlBlock>,
+}
+
+impl ParseState {
+    fn push_event(&mut self, range: Range<usize>, event: MarkdownEvent) {
+        match &event {
+            MarkdownEvent::Start(_) => {
+                if self.depth == 0 {
+                    self.root_block_starts.push(range.start);
+                    self.events.push((range.clone(), MarkdownEvent::RootStart));
+                }
+                self.depth += 1;
+                self.events.push((range, event));
+            }
+            MarkdownEvent::End(_) => {
+                self.events.push((range.clone(), event));
+                if self.depth > 0 {
+                    self.depth -= 1;
+                    if self.depth == 0 {
+                        let root_block_index = self.root_block_starts.len() - 1;
+                        self.events
+                            .push((range, MarkdownEvent::RootEnd(root_block_index)));
+                    }
+                }
+            }
+            MarkdownEvent::Rule => {
+                if self.depth == 0 && !range.is_empty() {
+                    self.root_block_starts.push(range.start);
+                    let root_block_index = self.root_block_starts.len() - 1;
+                    self.events.push((range.clone(), MarkdownEvent::RootStart));
+                    self.events.push((range.clone(), event));
+                    self.events
+                        .push((range, MarkdownEvent::RootEnd(root_block_index)));
+                } else {
+                    self.events.push((range, event));
+                }
+            }
+            _ => {
+                self.events.push((range, event));
+            }
+        }
+    }
+}
+
+pub(crate) fn parse_markdown_with_options(text: &str, parse_html: bool) -> ParsedMarkdownData {
+    let mut state = ParseState::default();
     let mut language_names = HashSet::default();
     let mut language_paths = HashSet::default();
+    let mut html_blocks = BTreeMap::default();
     let mut within_link = false;
     let mut within_metadata = false;
     let mut parser = Parser::new_ext(text, PARSE_OPTIONS)
@@ -48,6 +101,32 @@ pub fn parse_markdown(
         }
         match pulldown_event {
             pulldown_cmark::Event::Start(tag) => {
+                if let pulldown_cmark::Tag::HtmlBlock = &tag {
+                    state.push_event(range.clone(), MarkdownEvent::Start(MarkdownTag::HtmlBlock));
+
+                    if parse_html {
+                        if let Some(block) =
+                            html::html_parser::parse_html_block(&text[range.clone()], range.clone())
+                        {
+                            html_blocks.insert(range.start, block);
+
+                            while let Some((event, end_range)) = parser.next() {
+                                if let pulldown_cmark::Event::End(
+                                    pulldown_cmark::TagEnd::HtmlBlock,
+                                ) = event
+                                {
+                                    state.push_event(
+                                        end_range,
+                                        MarkdownEvent::End(MarkdownTagEnd::HtmlBlock),
+                                    );
+                                    break;
+                                }
+                            }
+                        }
+                    }
+                    continue;
+                }
+
                 let tag = match tag {
                     pulldown_cmark::Tag::Link {
                         link_type,
@@ -63,9 +142,9 @@ pub fn parse_markdown(
                             id: SharedString::from(id.into_string()),
                         }
                     }
-                    pulldown_cmark::Tag::MetadataBlock(kind) => {
+                    pulldown_cmark::Tag::MetadataBlock(_kind) => {
                         within_metadata = true;
-                        MarkdownTag::MetadataBlock(kind)
+                        continue;
                     }
                     pulldown_cmark::Tag::CodeBlock(pulldown_cmark::CodeBlockKind::Indented) => {
                         MarkdownTag::CodeBlock {
@@ -164,20 +243,20 @@ pub fn parse_markdown(
                         title: SharedString::from(title.into_string()),
                         id: SharedString::from(id.into_string()),
                     },
-                    pulldown_cmark::Tag::HtmlBlock => MarkdownTag::HtmlBlock,
+                    pulldown_cmark::Tag::HtmlBlock => MarkdownTag::HtmlBlock, // this is handled above separately
                     pulldown_cmark::Tag::DefinitionList => MarkdownTag::DefinitionList,
                     pulldown_cmark::Tag::DefinitionListTitle => MarkdownTag::DefinitionListTitle,
                     pulldown_cmark::Tag::DefinitionListDefinition => {
                         MarkdownTag::DefinitionListDefinition
                     }
                 };
-                events.push((range, MarkdownEvent::Start(tag)))
+                state.push_event(range, MarkdownEvent::Start(tag))
             }
             pulldown_cmark::Event::End(tag) => {
                 if let pulldown_cmark::TagEnd::Link = tag {
                     within_link = false;
                 }
-                events.push((range, MarkdownEvent::End(tag)));
+                state.push_event(range, MarkdownEvent::End(tag));
             }
             pulldown_cmark::Event::Text(parsed) => {
                 fn event_for(
@@ -205,16 +284,26 @@ pub fn parse_markdown(
                     parsed,
                 }];
 
-                while matches!(parser.peek(), Some((pulldown_cmark::Event::Text(_), _))) {
-                    let Some((pulldown_cmark::Event::Text(next_event), next_range)) = parser.next()
-                    else {
+                while matches!(parser.peek(), Some((pulldown_cmark::Event::Text(_), _)))
+                    || (parse_html
+                        && matches!(
+                            parser.peek(),
+                            Some((pulldown_cmark::Event::InlineHtml(_), _))
+                        ))
+                {
+                    let Some((next_event, next_range)) = parser.next() else {
                         unreachable!()
                     };
-                    let next_len = last_len + next_event.len();
+                    let next_text = match next_event {
+                        pulldown_cmark::Event::Text(next_event) => next_event,
+                        pulldown_cmark::Event::InlineHtml(_) => CowStr::Borrowed(""),
+                        _ => unreachable!(),
+                    };
+                    let next_len = last_len + next_text.len();
                     ranges.push(TextRange {
                         source_range: next_range.clone(),
                         merged_range: last_len..next_len,
-                        parsed: next_event,
+                        parsed: next_text,
                     });
                     last_len = next_len;
                 }
@@ -241,7 +330,8 @@ pub fn parse_markdown(
                             .is_some_and(|range| range.merged_range.end <= link_start_in_merged)
                         {
                             let range = ranges.next().unwrap();
-                            events.push(event_for(text, range.source_range, &range.parsed));
+                            let (range, event) = event_for(text, range.source_range, &range.parsed);
+                            state.push_event(range, event);
                         }
 
                         let Some(range) = ranges.peek_mut() else {
@@ -250,11 +340,12 @@ pub fn parse_markdown(
                         let prefix_len = link_start_in_merged - range.merged_range.start;
                         if prefix_len > 0 {
                             let (head, tail) = range.parsed.split_at(prefix_len);
-                            events.push(event_for(
+                            let (event_range, event) = event_for(
                                 text,
                                 range.source_range.start..range.source_range.start + prefix_len,
                                 head,
-                            ));
+                            );
+                            state.push_event(event_range, event);
                             range.parsed = CowStr::Boxed(tail.into());
                             range.merged_range.start += prefix_len;
                             range.source_range.start += prefix_len;
@@ -290,7 +381,7 @@ pub fn parse_markdown(
                         }
                         let link_range = link_start_in_source..link_end_in_source;
 
-                        events.push((
+                        state.push_event(
                             link_range.clone(),
                             MarkdownEvent::Start(MarkdownTag::Link {
                                 link_type: LinkType::Autolink,
@@ -298,37 +389,52 @@ pub fn parse_markdown(
                                 title: SharedString::default(),
                                 id: SharedString::default(),
                             }),
-                        ));
-                        events.extend(link_events);
-                        events.push((link_range.clone(), MarkdownEvent::End(MarkdownTagEnd::Link)));
+                        );
+                        for (range, event) in link_events {
+                            state.push_event(range, event);
+                        }
+                        state.push_event(
+                            link_range.clone(),
+                            MarkdownEvent::End(MarkdownTagEnd::Link),
+                        );
                     }
                 }
 
                 for range in ranges {
-                    events.push(event_for(text, range.source_range, &range.parsed));
+                    let (range, event) = event_for(text, range.source_range, &range.parsed);
+                    state.push_event(range, event);
                 }
             }
             pulldown_cmark::Event::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))
+                state.push_event(content_range, MarkdownEvent::Code)
+            }
+            pulldown_cmark::Event::Html(_) => state.push_event(range, MarkdownEvent::Html),
+            pulldown_cmark::Event::InlineHtml(_) => {
+                state.push_event(range, MarkdownEvent::InlineHtml)
             }
-            pulldown_cmark::Event::Html(_) => events.push((range, MarkdownEvent::Html)),
-            pulldown_cmark::Event::InlineHtml(_) => events.push((range, MarkdownEvent::InlineHtml)),
             pulldown_cmark::Event::FootnoteReference(_) => {
-                events.push((range, MarkdownEvent::FootnoteReference))
+                state.push_event(range, MarkdownEvent::FootnoteReference)
             }
-            pulldown_cmark::Event::SoftBreak => events.push((range, MarkdownEvent::SoftBreak)),
-            pulldown_cmark::Event::HardBreak => events.push((range, MarkdownEvent::HardBreak)),
-            pulldown_cmark::Event::Rule => events.push((range, MarkdownEvent::Rule)),
+            pulldown_cmark::Event::SoftBreak => state.push_event(range, MarkdownEvent::SoftBreak),
+            pulldown_cmark::Event::HardBreak => state.push_event(range, MarkdownEvent::HardBreak),
+            pulldown_cmark::Event::Rule => state.push_event(range, MarkdownEvent::Rule),
             pulldown_cmark::Event::TaskListMarker(checked) => {
-                events.push((range, MarkdownEvent::TaskListMarker(checked)))
+                state.push_event(range, MarkdownEvent::TaskListMarker(checked))
             }
             pulldown_cmark::Event::InlineMath(_) | pulldown_cmark::Event::DisplayMath(_) => {}
         }
     }
-    (events, language_names, language_paths)
+
+    ParsedMarkdownData {
+        events: state.events,
+        language_names,
+        language_paths,
+        root_block_starts: state.root_block_starts,
+        html_blocks,
+    }
 }
 
 pub fn parse_links_only(text: &str) -> Vec<(Range<usize>, MarkdownEvent)> {
@@ -401,6 +507,10 @@ pub enum MarkdownEvent {
     Rule,
     /// A task list marker, rendered as a checkbox in HTML. Contains a true when it is checked.
     TaskListMarker(bool),
+    /// Start of a root-level block (a top-level structural element like a paragraph, heading, list, etc.).
+    RootStart,
+    /// End of a root-level block. Contains the root block index.
+    RootEnd(usize),
 }
 
 /// Tags for elements that can contain other elements.
@@ -575,31 +685,39 @@ mod tests {
     #[test]
     fn test_html_comments() {
         assert_eq!(
-            parse_markdown("  <!--\nrdoc-file=string.c\n-->\nReturns"),
-            (
-                vec![
+            parse_markdown_with_options("  <!--\nrdoc-file=string.c\n-->\nReturns", false),
+            ParsedMarkdownData {
+                events: vec![
+                    (2..30, RootStart),
                     (2..30, Start(HtmlBlock)),
                     (2..2, SubstitutedText("  ".into())),
                     (2..7, Html),
                     (7..26, Html),
                     (26..30, Html),
                     (2..30, End(MarkdownTagEnd::HtmlBlock)),
+                    (2..30, RootEnd(0)),
+                    (30..37, RootStart),
                     (30..37, Start(Paragraph)),
                     (30..37, Text),
-                    (30..37, End(MarkdownTagEnd::Paragraph))
+                    (30..37, End(MarkdownTagEnd::Paragraph)),
+                    (30..37, RootEnd(1)),
                 ],
-                HashSet::default(),
-                HashSet::default()
-            )
+                root_block_starts: vec![2, 30],
+                ..Default::default()
+            }
         )
     }
 
     #[test]
     fn test_plain_urls_and_escaped_text() {
         assert_eq!(
-            parse_markdown("&nbsp;&nbsp; https://some.url some \\`&#9658;\\` text"),
-            (
-                vec![
+            parse_markdown_with_options(
+                "&nbsp;&nbsp; https://some.url some \\`&#9658;\\` text",
+                false
+            ),
+            ParsedMarkdownData {
+                events: vec![
+                    (0..51, RootStart),
                     (0..51, Start(Paragraph)),
                     (0..6, SubstitutedText("\u{a0}".into())),
                     (6..12, SubstitutedText("\u{a0}".into())),
@@ -620,19 +738,25 @@ mod tests {
                     (37..44, SubstitutedText("β–Ί".into())),
                     (45..46, Text), // Escaped backtick
                     (46..51, Text),
-                    (0..51, End(MarkdownTagEnd::Paragraph))
+                    (0..51, End(MarkdownTagEnd::Paragraph)),
+                    (0..51, RootEnd(0)),
                 ],
-                HashSet::default(),
-                HashSet::default()
-            )
+                root_block_starts: vec![0],
+                ..Default::default()
+            }
         );
     }
 
     #[test]
     fn test_incomplete_link() {
         assert_eq!(
-            parse_markdown("You can use the [GitHub Search API](https://docs.github.com/en").0,
+            parse_markdown_with_options(
+                "You can use the [GitHub Search API](https://docs.github.com/en",
+                false
+            )
+            .events,
             vec![
+                (0..62, RootStart),
                 (0..62, Start(Paragraph)),
                 (0..16, Text),
                 (16..17, Text),
@@ -650,7 +774,8 @@ mod tests {
                 ),
                 (36..62, Text),
                 (36..62, End(MarkdownTagEnd::Link)),
-                (0..62, End(MarkdownTagEnd::Paragraph))
+                (0..62, End(MarkdownTagEnd::Paragraph)),
+                (0..62, RootEnd(0)),
             ],
         );
     }
@@ -658,9 +783,13 @@ mod tests {
     #[test]
     fn test_smart_punctuation() {
         assert_eq!(
-            parse_markdown("-- --- ... \"double quoted\" 'single quoted' ----------"),
-            (
-                vec![
+            parse_markdown_with_options(
+                "-- --- ... \"double quoted\" 'single quoted' ----------",
+                false
+            ),
+            ParsedMarkdownData {
+                events: vec![
+                    (0..53, RootStart),
                     (0..53, Start(Paragraph)),
                     (0..2, SubstitutedText("–".into())),
                     (2..3, Text),
@@ -668,29 +797,31 @@ mod tests {
                     (6..7, Text),
                     (7..10, SubstitutedText("…".into())),
                     (10..11, Text),
-                    (11..12, SubstitutedText("β€œ".into())),
+                    (11..12, SubstitutedText("\u{201c}".into())),
                     (12..25, Text),
-                    (25..26, SubstitutedText("”".into())),
+                    (25..26, SubstitutedText("\u{201d}".into())),
                     (26..27, Text),
-                    (27..28, SubstitutedText("β€˜".into())),
+                    (27..28, SubstitutedText("\u{2018}".into())),
                     (28..41, Text),
-                    (41..42, SubstitutedText("’".into())),
+                    (41..42, SubstitutedText("\u{2019}".into())),
                     (42..43, Text),
                     (43..53, SubstitutedText("–––––".into())),
-                    (0..53, End(MarkdownTagEnd::Paragraph))
+                    (0..53, End(MarkdownTagEnd::Paragraph)),
+                    (0..53, RootEnd(0)),
                 ],
-                HashSet::default(),
-                HashSet::default()
-            )
+                root_block_starts: vec![0],
+                ..Default::default()
+            }
         )
     }
 
     #[test]
     fn test_code_block_metadata() {
         assert_eq!(
-            parse_markdown("```rust\nfn main() {\n let a = 1;\n}\n```"),
-            (
-                vec![
+            parse_markdown_with_options("```rust\nfn main() {\n let a = 1;\n}\n```", false),
+            ParsedMarkdownData {
+                events: vec![
+                    (0..37, RootStart),
                     (
                         0..37,
                         Start(CodeBlock {
@@ -703,19 +834,22 @@ mod tests {
                     ),
                     (8..34, Text),
                     (0..37, End(MarkdownTagEnd::CodeBlock)),
+                    (0..37, RootEnd(0)),
                 ],
-                {
+                language_names: {
                     let mut h = HashSet::default();
                     h.insert("rust".into());
                     h
                 },
-                HashSet::default()
-            )
+                root_block_starts: vec![0],
+                ..Default::default()
+            }
         );
         assert_eq!(
-            parse_markdown("    fn main() {}"),
-            (
-                vec![
+            parse_markdown_with_options("    fn main() {}", false),
+            ParsedMarkdownData {
+                events: vec![
+                    (4..16, RootStart),
                     (
                         4..16,
                         Start(CodeBlock {
@@ -727,14 +861,76 @@ mod tests {
                         })
                     ),
                     (4..16, Text),
-                    (4..16, End(MarkdownTagEnd::CodeBlock))
+                    (4..16, End(MarkdownTagEnd::CodeBlock)),
+                    (4..16, RootEnd(0)),
                 ],
-                HashSet::default(),
-                HashSet::default()
-            )
+                root_block_starts: vec![4],
+                ..Default::default()
+            }
         );
     }
 
+    #[test]
+    fn test_metadata_blocks_do_not_affect_root_blocks() {
+        assert_eq!(
+            parse_markdown_with_options("+++\ntitle = \"Example\"\n+++\n\nParagraph", false),
+            ParsedMarkdownData {
+                events: vec![
+                    (27..36, RootStart),
+                    (27..36, Start(Paragraph)),
+                    (27..36, Text),
+                    (27..36, End(MarkdownTagEnd::Paragraph)),
+                    (27..36, RootEnd(0)),
+                ],
+                root_block_starts: vec![27],
+                ..Default::default()
+            }
+        );
+    }
+
+    #[test]
+    fn test_table_checkboxes_remain_text_in_cells() {
+        let markdown = "\
+| Done | Task    |
+|------|---------|
+| [x]  | Fix bug |
+| [ ]  | Add feature |";
+        let parsed = parse_markdown_with_options(markdown, false);
+
+        let mut in_table = false;
+        let mut saw_task_list_marker = false;
+        let mut cell_texts = Vec::new();
+        let mut current_cell = String::new();
+
+        for (range, event) in &parsed.events {
+            match event {
+                Start(Table(_)) => in_table = true,
+                End(MarkdownTagEnd::Table) => in_table = false,
+                Start(TableCell) => current_cell.clear(),
+                End(MarkdownTagEnd::TableCell) => {
+                    if in_table {
+                        cell_texts.push(current_cell.clone());
+                    }
+                }
+                Text if in_table => current_cell.push_str(&markdown[range.clone()]),
+                TaskListMarker(_) if in_table => saw_task_list_marker = true,
+                _ => {}
+            }
+        }
+
+        let checkbox_cells: Vec<&str> = cell_texts
+            .iter()
+            .map(|cell| cell.trim())
+            .filter(|cell| *cell == "[x]" || *cell == "[X]" || *cell == "[ ]")
+            .collect();
+
+        assert!(
+            !saw_task_list_marker,
+            "Table checkboxes should remain text, not task-list markers"
+        );
+        assert_eq!(checkbox_cells, vec!["[x]", "[ ]"]);
+    }
+
     #[test]
     fn test_extract_code_content_range() {
         let input = "```let x = 5;```";
@@ -776,8 +972,13 @@ mod tests {
         // Note: In real usage, pulldown_cmark creates separate text events for the escaped character
         // We're verifying our parser can handle this correctly
         assert_eq!(
-            parse_markdown("https:/\\/example.com is equivalent to https://example&#46;com!").0,
+            parse_markdown_with_options(
+                "https:/\\/example.com is equivalent to https://example&#46;com!",
+                false
+            )
+            .events,
             vec![
+                (0..62, RootStart),
                 (0..62, Start(Paragraph)),
                 (
                     0..20,
@@ -806,13 +1007,19 @@ mod tests {
                 (58..61, Text),
                 (38..61, End(MarkdownTagEnd::Link)),
                 (61..62, Text),
-                (0..62, End(MarkdownTagEnd::Paragraph))
+                (0..62, End(MarkdownTagEnd::Paragraph)),
+                (0..62, RootEnd(0)),
             ],
         );
 
         assert_eq!(
-            parse_markdown("Visit https://example.com/cat\\/Γ©&#8205;β˜• for coffee!").0,
+            parse_markdown_with_options(
+                "Visit https://example.com/cat\\/Γ©&#8205;β˜• for coffee!",
+                false
+            )
+            .events,
             [
+                (0..55, RootStart),
                 (0..55, Start(Paragraph)),
                 (0..6, Text),
                 (
@@ -830,7 +1037,8 @@ mod tests {
                 (40..43, Text),
                 (6..43, End(MarkdownTagEnd::Link)),
                 (43..55, Text),
-                (0..55, End(MarkdownTagEnd::Paragraph))
+                (0..55, End(MarkdownTagEnd::Paragraph)),
+                (0..55, RootEnd(0)),
             ]
         );
     }

crates/markdown_preview/Cargo.toml πŸ”—

@@ -16,28 +16,18 @@ test-support = []
 
 [dependencies]
 anyhow.workspace = true
-async-recursion.workspace = true
-collections.workspace = true
 editor.workspace = true
 gpui.workspace = true
-html5ever.workspace = true
 language.workspace = true
-linkify.workspace = true
 log.workspace = true
 markdown.workspace = true
-markup5ever_rcdom.workspace = true
-pretty_assertions.workspace = true
-pulldown-cmark.workspace = true
 settings.workspace = true
-stacksafe.workspace = true
 theme.workspace = true
 ui.workspace = true
 urlencoding.workspace = true
 util.workspace = true
 workspace.workspace = true
 zed_actions.workspace = true
-mermaid-rs-renderer.workspace = true
 
 [dev-dependencies]
-editor = { workspace = true, features = ["test-support"] }
-language = { workspace = true, features = ["test-support"] }
+tempfile.workspace = true

crates/markdown_preview/src/markdown_elements.rs πŸ”—

@@ -1,373 +0,0 @@
-use gpui::{
-    DefiniteLength, FontStyle, FontWeight, HighlightStyle, SharedString, StrikethroughStyle,
-    UnderlineStyle, px,
-};
-use language::HighlightId;
-use std::{fmt::Display, ops::Range, path::PathBuf};
-use urlencoding;
-
-#[derive(Debug)]
-#[cfg_attr(test, derive(PartialEq))]
-pub enum ParsedMarkdownElement {
-    Heading(ParsedMarkdownHeading),
-    ListItem(ParsedMarkdownListItem),
-    Table(ParsedMarkdownTable),
-    BlockQuote(ParsedMarkdownBlockQuote),
-    CodeBlock(ParsedMarkdownCodeBlock),
-    MermaidDiagram(ParsedMarkdownMermaidDiagram),
-    /// A paragraph of text and other inline elements.
-    Paragraph(MarkdownParagraph),
-    HorizontalRule(Range<usize>),
-    Image(Image),
-}
-
-impl ParsedMarkdownElement {
-    pub fn source_range(&self) -> Option<Range<usize>> {
-        Some(match self {
-            Self::Heading(heading) => heading.source_range.clone(),
-            Self::ListItem(list_item) => list_item.source_range.clone(),
-            Self::Table(table) => table.source_range.clone(),
-            Self::BlockQuote(block_quote) => block_quote.source_range.clone(),
-            Self::CodeBlock(code_block) => code_block.source_range.clone(),
-            Self::MermaidDiagram(mermaid) => mermaid.source_range.clone(),
-            Self::Paragraph(text) => match text.get(0)? {
-                MarkdownParagraphChunk::Text(t) => t.source_range.clone(),
-                MarkdownParagraphChunk::Image(image) => image.source_range.clone(),
-            },
-            Self::HorizontalRule(range) => range.clone(),
-            Self::Image(image) => image.source_range.clone(),
-        })
-    }
-
-    pub fn is_list_item(&self) -> bool {
-        matches!(self, Self::ListItem(_))
-    }
-}
-
-pub type MarkdownParagraph = Vec<MarkdownParagraphChunk>;
-
-#[derive(Debug)]
-#[cfg_attr(test, derive(PartialEq))]
-pub enum MarkdownParagraphChunk {
-    Text(ParsedMarkdownText),
-    Image(Image),
-}
-
-#[derive(Debug)]
-#[cfg_attr(test, derive(PartialEq))]
-pub struct ParsedMarkdown {
-    pub children: Vec<ParsedMarkdownElement>,
-}
-
-#[derive(Debug)]
-#[cfg_attr(test, derive(PartialEq))]
-pub struct ParsedMarkdownListItem {
-    pub source_range: Range<usize>,
-    /// How many indentations deep this item is.
-    pub depth: u16,
-    pub item_type: ParsedMarkdownListItemType,
-    pub content: Vec<ParsedMarkdownElement>,
-    /// Whether we can expect nested list items inside of this items `content`.
-    pub nested: bool,
-}
-
-#[derive(Debug)]
-#[cfg_attr(test, derive(PartialEq))]
-pub enum ParsedMarkdownListItemType {
-    Ordered(u64),
-    Task(bool, Range<usize>),
-    Unordered,
-}
-
-#[derive(Debug)]
-#[cfg_attr(test, derive(PartialEq))]
-pub struct ParsedMarkdownCodeBlock {
-    pub source_range: Range<usize>,
-    pub language: Option<String>,
-    pub contents: SharedString,
-    pub highlights: Option<Vec<(Range<usize>, HighlightId)>>,
-}
-
-#[derive(Debug)]
-#[cfg_attr(test, derive(PartialEq))]
-pub struct ParsedMarkdownMermaidDiagram {
-    pub source_range: Range<usize>,
-    pub contents: ParsedMarkdownMermaidDiagramContents,
-}
-
-#[derive(Clone, Debug, PartialEq, Eq, Hash)]
-pub struct ParsedMarkdownMermaidDiagramContents {
-    pub contents: SharedString,
-    pub scale: u32,
-}
-
-#[derive(Debug)]
-#[cfg_attr(test, derive(PartialEq))]
-pub struct ParsedMarkdownHeading {
-    pub source_range: Range<usize>,
-    pub level: HeadingLevel,
-    pub contents: MarkdownParagraph,
-}
-
-#[derive(Debug, PartialEq)]
-pub enum HeadingLevel {
-    H1,
-    H2,
-    H3,
-    H4,
-    H5,
-    H6,
-}
-
-#[derive(Debug)]
-pub struct ParsedMarkdownTable {
-    pub source_range: Range<usize>,
-    pub header: Vec<ParsedMarkdownTableRow>,
-    pub body: Vec<ParsedMarkdownTableRow>,
-    pub caption: Option<MarkdownParagraph>,
-}
-
-#[derive(Debug, Clone, Copy, Default)]
-#[cfg_attr(test, derive(PartialEq))]
-pub enum ParsedMarkdownTableAlignment {
-    #[default]
-    None,
-    Left,
-    Center,
-    Right,
-}
-
-#[derive(Debug)]
-#[cfg_attr(test, derive(PartialEq))]
-pub struct ParsedMarkdownTableColumn {
-    pub col_span: usize,
-    pub row_span: usize,
-    pub is_header: bool,
-    pub children: MarkdownParagraph,
-    pub alignment: ParsedMarkdownTableAlignment,
-}
-
-#[derive(Debug)]
-#[cfg_attr(test, derive(PartialEq))]
-pub struct ParsedMarkdownTableRow {
-    pub columns: Vec<ParsedMarkdownTableColumn>,
-}
-
-impl Default for ParsedMarkdownTableRow {
-    fn default() -> Self {
-        Self::new()
-    }
-}
-
-impl ParsedMarkdownTableRow {
-    pub fn new() -> Self {
-        Self {
-            columns: Vec::new(),
-        }
-    }
-
-    pub fn with_columns(columns: Vec<ParsedMarkdownTableColumn>) -> Self {
-        Self { columns }
-    }
-}
-
-#[derive(Debug)]
-#[cfg_attr(test, derive(PartialEq))]
-pub struct ParsedMarkdownBlockQuote {
-    pub source_range: Range<usize>,
-    pub children: Vec<ParsedMarkdownElement>,
-}
-
-#[derive(Debug, Clone)]
-pub struct ParsedMarkdownText {
-    /// Where the text is located in the source Markdown document.
-    pub source_range: Range<usize>,
-    /// The text content stripped of any formatting symbols.
-    pub contents: SharedString,
-    /// The list of highlights contained in the Markdown document.
-    pub highlights: Vec<(Range<usize>, MarkdownHighlight)>,
-    /// The regions of the Markdown document.
-    pub regions: Vec<(Range<usize>, ParsedRegion)>,
-}
-
-/// A run of highlighted Markdown text.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum MarkdownHighlight {
-    /// A styled Markdown highlight.
-    Style(MarkdownHighlightStyle),
-    /// A highlighted code block.
-    Code(HighlightId),
-}
-
-impl MarkdownHighlight {
-    /// Converts this [`MarkdownHighlight`] to a [`HighlightStyle`].
-    pub fn to_highlight_style(&self, theme: &theme::SyntaxTheme) -> Option<HighlightStyle> {
-        match self {
-            MarkdownHighlight::Style(style) => {
-                let mut highlight = HighlightStyle::default();
-
-                if style.italic {
-                    highlight.font_style = Some(FontStyle::Italic);
-                }
-
-                if style.underline {
-                    highlight.underline = Some(UnderlineStyle {
-                        thickness: px(1.),
-                        ..Default::default()
-                    });
-                }
-
-                if style.strikethrough {
-                    highlight.strikethrough = Some(StrikethroughStyle {
-                        thickness: px(1.),
-                        ..Default::default()
-                    });
-                }
-
-                if style.weight != FontWeight::default() {
-                    highlight.font_weight = Some(style.weight);
-                }
-
-                if style.link {
-                    highlight.underline = Some(UnderlineStyle {
-                        thickness: px(1.),
-                        ..Default::default()
-                    });
-                }
-
-                if style.oblique {
-                    highlight.font_style = Some(FontStyle::Oblique)
-                }
-
-                Some(highlight)
-            }
-
-            MarkdownHighlight::Code(id) => id.style(theme),
-        }
-    }
-}
-
-/// The style for a Markdown highlight.
-#[derive(Debug, Clone, Default, PartialEq, Eq)]
-pub struct MarkdownHighlightStyle {
-    /// Whether the text should be italicized.
-    pub italic: bool,
-    /// Whether the text should be underlined.
-    pub underline: bool,
-    /// Whether the text should be struck through.
-    pub strikethrough: bool,
-    /// The weight of the text.
-    pub weight: FontWeight,
-    /// Whether the text should be stylized as link.
-    pub link: bool,
-    // Whether the text should be obliqued.
-    pub oblique: bool,
-}
-
-/// A parsed region in a Markdown document.
-#[derive(Debug, Clone)]
-#[cfg_attr(test, derive(PartialEq))]
-pub struct ParsedRegion {
-    /// Whether the region is a code block.
-    pub code: bool,
-    /// The link contained in this region, if it has one.
-    pub link: Option<Link>,
-}
-
-/// A Markdown link.
-#[derive(Debug, Clone)]
-#[cfg_attr(test, derive(PartialEq))]
-pub enum Link {
-    /// A link to a webpage.
-    Web {
-        /// The URL of the webpage.
-        url: String,
-    },
-    /// A link to a path on the filesystem.
-    Path {
-        /// The path as provided in the Markdown document.
-        display_path: PathBuf,
-        /// The absolute path to the item.
-        path: PathBuf,
-    },
-}
-
-impl Link {
-    pub fn identify(file_location_directory: Option<PathBuf>, text: String) -> Option<Link> {
-        if text.starts_with("http") {
-            return Some(Link::Web { url: text });
-        }
-
-        // URL decode the text to handle spaces and other special characters
-        let decoded_text = urlencoding::decode(&text)
-            .map(|s| s.into_owned())
-            .unwrap_or(text);
-
-        let path = PathBuf::from(&decoded_text);
-        if path.is_absolute() && path.exists() {
-            return Some(Link::Path {
-                display_path: path.clone(),
-                path,
-            });
-        }
-
-        if let Some(file_location_directory) = file_location_directory {
-            let display_path = path;
-            let path = file_location_directory.join(decoded_text);
-            if path.exists() {
-                return Some(Link::Path { display_path, path });
-            }
-        }
-
-        None
-    }
-}
-
-impl Display for Link {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            Link::Web { url } => write!(f, "{}", url),
-            Link::Path { display_path, .. } => write!(f, "{}", display_path.display()),
-        }
-    }
-}
-
-/// A Markdown Image
-#[derive(Debug, Clone)]
-#[cfg_attr(test, derive(PartialEq))]
-pub struct Image {
-    pub link: Link,
-    pub source_range: Range<usize>,
-    pub alt_text: Option<SharedString>,
-    pub width: Option<DefiniteLength>,
-    pub height: Option<DefiniteLength>,
-}
-
-impl Image {
-    pub fn identify(
-        text: String,
-        source_range: Range<usize>,
-        file_location_directory: Option<PathBuf>,
-    ) -> Option<Self> {
-        let link = Link::identify(file_location_directory, text)?;
-        Some(Self {
-            source_range,
-            link,
-            alt_text: None,
-            width: None,
-            height: None,
-        })
-    }
-
-    pub fn set_alt_text(&mut self, alt_text: SharedString) {
-        self.alt_text = Some(alt_text);
-    }
-
-    pub fn set_width(&mut self, width: DefiniteLength) {
-        self.width = Some(width);
-    }
-
-    pub fn set_height(&mut self, height: DefiniteLength) {
-        self.height = Some(height);
-    }
-}

crates/markdown_preview/src/markdown_parser.rs πŸ”—

@@ -1,3320 +0,0 @@
-use crate::{
-    markdown_elements::*,
-    markdown_minifier::{Minifier, MinifierOptions},
-};
-use async_recursion::async_recursion;
-use collections::FxHashMap;
-use gpui::{DefiniteLength, FontWeight, px, relative};
-use html5ever::{ParseOpts, local_name, parse_document, tendril::TendrilSink};
-use language::LanguageRegistry;
-use markdown::parser::PARSE_OPTIONS;
-use markup5ever_rcdom::RcDom;
-use pulldown_cmark::{Alignment, Event, Parser, Tag, TagEnd};
-use stacksafe::stacksafe;
-use std::{
-    cell::RefCell, collections::HashMap, mem, ops::Range, path::PathBuf, rc::Rc, sync::Arc, vec,
-};
-use ui::SharedString;
-
-pub async fn parse_markdown(
-    markdown_input: &str,
-    file_location_directory: Option<PathBuf>,
-    language_registry: Option<Arc<LanguageRegistry>>,
-) -> ParsedMarkdown {
-    let parser = Parser::new_ext(markdown_input, PARSE_OPTIONS);
-    let parser = MarkdownParser::new(
-        parser.into_offset_iter().collect(),
-        file_location_directory,
-        language_registry,
-    );
-    let renderer = parser.parse_document().await;
-    ParsedMarkdown {
-        children: renderer.parsed,
-    }
-}
-
-fn cleanup_html(source: &str) -> Vec<u8> {
-    let mut writer = std::io::Cursor::new(Vec::new());
-    let mut reader = std::io::Cursor::new(source);
-    let mut minify = Minifier::new(
-        &mut writer,
-        MinifierOptions {
-            omit_doctype: true,
-            collapse_whitespace: true,
-            ..Default::default()
-        },
-    );
-    if let Ok(()) = minify.minify(&mut reader) {
-        writer.into_inner()
-    } else {
-        source.bytes().collect()
-    }
-}
-
-struct MarkdownParser<'a> {
-    tokens: Vec<(Event<'a>, Range<usize>)>,
-    /// The current index in the tokens array
-    cursor: usize,
-    /// The blocks that we have successfully parsed so far
-    parsed: Vec<ParsedMarkdownElement>,
-    file_location_directory: Option<PathBuf>,
-    language_registry: Option<Arc<LanguageRegistry>>,
-}
-
-#[derive(Debug)]
-struct ParseHtmlNodeContext {
-    list_item_depth: u16,
-}
-
-impl Default for ParseHtmlNodeContext {
-    fn default() -> Self {
-        Self { list_item_depth: 1 }
-    }
-}
-
-struct MarkdownListItem {
-    content: Vec<ParsedMarkdownElement>,
-    item_type: ParsedMarkdownListItemType,
-}
-
-impl Default for MarkdownListItem {
-    fn default() -> Self {
-        Self {
-            content: Vec::new(),
-            item_type: ParsedMarkdownListItemType::Unordered,
-        }
-    }
-}
-
-impl<'a> MarkdownParser<'a> {
-    fn new(
-        tokens: Vec<(Event<'a>, Range<usize>)>,
-        file_location_directory: Option<PathBuf>,
-        language_registry: Option<Arc<LanguageRegistry>>,
-    ) -> Self {
-        Self {
-            tokens,
-            file_location_directory,
-            language_registry,
-            cursor: 0,
-            parsed: vec![],
-        }
-    }
-
-    fn eof(&self) -> bool {
-        if self.tokens.is_empty() {
-            return true;
-        }
-        self.cursor >= self.tokens.len() - 1
-    }
-
-    fn peek(&self, steps: usize) -> Option<&(Event<'_>, Range<usize>)> {
-        if self.eof() || (steps + self.cursor) >= self.tokens.len() {
-            return self.tokens.last();
-        }
-        self.tokens.get(self.cursor + steps)
-    }
-
-    fn previous(&self) -> Option<&(Event<'_>, Range<usize>)> {
-        if self.cursor == 0 || self.cursor > self.tokens.len() {
-            return None;
-        }
-        self.tokens.get(self.cursor - 1)
-    }
-
-    fn current(&self) -> Option<&(Event<'_>, Range<usize>)> {
-        self.peek(0)
-    }
-
-    fn current_event(&self) -> Option<&Event<'_>> {
-        self.current().map(|(event, _)| event)
-    }
-
-    fn is_text_like(event: &Event) -> bool {
-        match event {
-            Event::Text(_)
-            // Represent an inline code block
-            | Event::Code(_)
-            | Event::Html(_)
-            | Event::InlineHtml(_)
-            | Event::FootnoteReference(_)
-            | Event::Start(Tag::Link { .. })
-            | Event::Start(Tag::Emphasis)
-            | Event::Start(Tag::Strong)
-            | Event::Start(Tag::Strikethrough)
-            | Event::Start(Tag::Image { .. }) => {
-                true
-            }
-            _ => false,
-        }
-    }
-
-    async fn parse_document(mut self) -> Self {
-        while !self.eof() {
-            if let Some(block) = self.parse_block().await {
-                self.parsed.extend(block);
-            } else {
-                self.cursor += 1;
-            }
-        }
-        self
-    }
-
-    #[async_recursion]
-    async fn parse_block(&mut self) -> Option<Vec<ParsedMarkdownElement>> {
-        let (current, source_range) = self.current().unwrap();
-        let source_range = source_range.clone();
-        match current {
-            Event::Start(tag) => match tag {
-                Tag::Paragraph => {
-                    self.cursor += 1;
-                    let text = self.parse_text(false, Some(source_range));
-                    Some(vec![ParsedMarkdownElement::Paragraph(text)])
-                }
-                Tag::Heading { level, .. } => {
-                    let level = *level;
-                    self.cursor += 1;
-                    let heading = self.parse_heading(level);
-                    Some(vec![ParsedMarkdownElement::Heading(heading)])
-                }
-                Tag::Table(alignment) => {
-                    let alignment = alignment.clone();
-                    self.cursor += 1;
-                    let table = self.parse_table(alignment);
-                    Some(vec![ParsedMarkdownElement::Table(table)])
-                }
-                Tag::List(order) => {
-                    let order = *order;
-                    self.cursor += 1;
-                    let list = self.parse_list(order).await;
-                    Some(list)
-                }
-                Tag::BlockQuote(_kind) => {
-                    self.cursor += 1;
-                    let block_quote = self.parse_block_quote().await;
-                    Some(vec![ParsedMarkdownElement::BlockQuote(block_quote)])
-                }
-                Tag::CodeBlock(kind) => {
-                    let (language, scale) = match kind {
-                        pulldown_cmark::CodeBlockKind::Indented => (None, None),
-                        pulldown_cmark::CodeBlockKind::Fenced(language) => {
-                            if language.is_empty() {
-                                (None, None)
-                            } else {
-                                let parts: Vec<&str> = language.split_whitespace().collect();
-                                let lang = parts.first().map(|s| s.to_string());
-                                let scale = parts.get(1).and_then(|s| s.parse::<u32>().ok());
-                                (lang, scale)
-                            }
-                        }
-                    };
-
-                    self.cursor += 1;
-
-                    if language.as_deref() == Some("mermaid") {
-                        let mermaid_diagram = self.parse_mermaid_diagram(scale).await?;
-                        Some(vec![ParsedMarkdownElement::MermaidDiagram(mermaid_diagram)])
-                    } else {
-                        let code_block = self.parse_code_block(language).await?;
-                        Some(vec![ParsedMarkdownElement::CodeBlock(code_block)])
-                    }
-                }
-                Tag::HtmlBlock => {
-                    self.cursor += 1;
-
-                    Some(self.parse_html_block().await)
-                }
-                _ => None,
-            },
-            Event::Rule => {
-                self.cursor += 1;
-                Some(vec![ParsedMarkdownElement::HorizontalRule(source_range)])
-            }
-            _ => None,
-        }
-    }
-
-    fn parse_text(
-        &mut self,
-        should_complete_on_soft_break: bool,
-        source_range: Option<Range<usize>>,
-    ) -> MarkdownParagraph {
-        let source_range = source_range.unwrap_or_else(|| {
-            self.current()
-                .map(|(_, range)| range.clone())
-                .unwrap_or_default()
-        });
-
-        let mut markdown_text_like = Vec::new();
-        let mut text = String::new();
-        let mut bold_depth = 0;
-        let mut italic_depth = 0;
-        let mut strikethrough_depth = 0;
-        let mut link: Option<Link> = None;
-        let mut image: Option<Image> = None;
-        let mut regions: Vec<(Range<usize>, ParsedRegion)> = vec![];
-        let mut highlights: Vec<(Range<usize>, MarkdownHighlight)> = vec![];
-        let mut link_urls: Vec<String> = vec![];
-        let mut link_ranges: Vec<Range<usize>> = vec![];
-
-        loop {
-            if self.eof() {
-                break;
-            }
-
-            let (current, _) = self.current().unwrap();
-            let prev_len = text.len();
-            match current {
-                Event::SoftBreak => {
-                    if should_complete_on_soft_break {
-                        break;
-                    }
-                    text.push(' ');
-                }
-
-                Event::HardBreak => {
-                    text.push('\n');
-                }
-
-                // We want to ignore any inline HTML tags in the text but keep
-                // the text between them
-                Event::InlineHtml(_) => {}
-
-                Event::Text(t) => {
-                    text.push_str(t.as_ref());
-                    let mut style = MarkdownHighlightStyle::default();
-
-                    if bold_depth > 0 {
-                        style.weight = FontWeight::BOLD;
-                    }
-
-                    if italic_depth > 0 {
-                        style.italic = true;
-                    }
-
-                    if strikethrough_depth > 0 {
-                        style.strikethrough = true;
-                    }
-
-                    let last_run_len = if let Some(link) = link.clone() {
-                        regions.push((
-                            prev_len..text.len(),
-                            ParsedRegion {
-                                code: false,
-                                link: Some(link),
-                            },
-                        ));
-                        style.link = true;
-                        prev_len
-                    } else {
-                        // Manually scan for links
-                        let mut finder = linkify::LinkFinder::new();
-                        finder.kinds(&[linkify::LinkKind::Url]);
-                        let mut last_link_len = prev_len;
-                        for link in finder.links(t) {
-                            let start = prev_len + link.start();
-                            let end = prev_len + link.end();
-                            let range = start..end;
-                            link_ranges.push(range.clone());
-                            link_urls.push(link.as_str().to_string());
-
-                            // If there is a style before we match a link, we have to add this to the highlighted ranges
-                            if style != MarkdownHighlightStyle::default() && last_link_len < start {
-                                highlights.push((
-                                    last_link_len..start,
-                                    MarkdownHighlight::Style(style.clone()),
-                                ));
-                            }
-
-                            highlights.push((
-                                range.clone(),
-                                MarkdownHighlight::Style(MarkdownHighlightStyle {
-                                    underline: true,
-                                    ..style
-                                }),
-                            ));
-
-                            regions.push((
-                                range.clone(),
-                                ParsedRegion {
-                                    code: false,
-                                    link: Some(Link::Web {
-                                        url: link.as_str().to_string(),
-                                    }),
-                                },
-                            ));
-                            last_link_len = end;
-                        }
-                        last_link_len
-                    };
-
-                    if style != MarkdownHighlightStyle::default() && last_run_len < text.len() {
-                        let mut new_highlight = true;
-                        if let Some((last_range, last_style)) = highlights.last_mut()
-                            && last_range.end == last_run_len
-                            && last_style == &MarkdownHighlight::Style(style.clone())
-                        {
-                            last_range.end = text.len();
-                            new_highlight = false;
-                        }
-                        if new_highlight {
-                            highlights.push((
-                                last_run_len..text.len(),
-                                MarkdownHighlight::Style(style.clone()),
-                            ));
-                        }
-                    }
-                }
-                Event::Code(t) => {
-                    text.push_str(t.as_ref());
-                    let range = prev_len..text.len();
-
-                    if link.is_some() {
-                        highlights.push((
-                            range.clone(),
-                            MarkdownHighlight::Style(MarkdownHighlightStyle {
-                                link: true,
-                                ..Default::default()
-                            }),
-                        ));
-                    }
-                    regions.push((
-                        range,
-                        ParsedRegion {
-                            code: true,
-                            link: link.clone(),
-                        },
-                    ));
-                }
-                Event::Start(tag) => match tag {
-                    Tag::Emphasis => italic_depth += 1,
-                    Tag::Strong => bold_depth += 1,
-                    Tag::Strikethrough => strikethrough_depth += 1,
-                    Tag::Link { dest_url, .. } => {
-                        link = Link::identify(
-                            self.file_location_directory.clone(),
-                            dest_url.to_string(),
-                        );
-                    }
-                    Tag::Image { dest_url, .. } => {
-                        if !text.is_empty() {
-                            let parsed_regions = MarkdownParagraphChunk::Text(ParsedMarkdownText {
-                                source_range: source_range.clone(),
-                                contents: mem::take(&mut text).into(),
-                                highlights: mem::take(&mut highlights),
-                                regions: mem::take(&mut regions),
-                            });
-                            markdown_text_like.push(parsed_regions);
-                        }
-                        image = Image::identify(
-                            dest_url.to_string(),
-                            source_range.clone(),
-                            self.file_location_directory.clone(),
-                        );
-                    }
-                    _ => {
-                        break;
-                    }
-                },
-
-                Event::End(tag) => match tag {
-                    TagEnd::Emphasis => italic_depth -= 1,
-                    TagEnd::Strong => bold_depth -= 1,
-                    TagEnd::Strikethrough => strikethrough_depth -= 1,
-                    TagEnd::Link => {
-                        link = None;
-                    }
-                    TagEnd::Image => {
-                        if let Some(mut image) = image.take() {
-                            if !text.is_empty() {
-                                image.set_alt_text(std::mem::take(&mut text).into());
-                                mem::take(&mut highlights);
-                                mem::take(&mut regions);
-                            }
-                            markdown_text_like.push(MarkdownParagraphChunk::Image(image));
-                        }
-                    }
-                    TagEnd::Paragraph => {
-                        self.cursor += 1;
-                        break;
-                    }
-                    _ => {
-                        break;
-                    }
-                },
-                _ => {
-                    break;
-                }
-            }
-
-            self.cursor += 1;
-        }
-        if !text.is_empty() {
-            markdown_text_like.push(MarkdownParagraphChunk::Text(ParsedMarkdownText {
-                source_range,
-                contents: text.into(),
-                highlights,
-                regions,
-            }));
-        }
-        markdown_text_like
-    }
-
-    fn parse_heading(&mut self, level: pulldown_cmark::HeadingLevel) -> ParsedMarkdownHeading {
-        let (_event, source_range) = self.previous().unwrap();
-        let source_range = source_range.clone();
-        let text = self.parse_text(true, None);
-
-        // Advance past the heading end tag
-        self.cursor += 1;
-
-        ParsedMarkdownHeading {
-            source_range,
-            level: match level {
-                pulldown_cmark::HeadingLevel::H1 => HeadingLevel::H1,
-                pulldown_cmark::HeadingLevel::H2 => HeadingLevel::H2,
-                pulldown_cmark::HeadingLevel::H3 => HeadingLevel::H3,
-                pulldown_cmark::HeadingLevel::H4 => HeadingLevel::H4,
-                pulldown_cmark::HeadingLevel::H5 => HeadingLevel::H5,
-                pulldown_cmark::HeadingLevel::H6 => HeadingLevel::H6,
-            },
-            contents: text,
-        }
-    }
-
-    fn parse_table(&mut self, alignment: Vec<Alignment>) -> ParsedMarkdownTable {
-        let (_event, source_range) = self.previous().unwrap();
-        let source_range = source_range.clone();
-        let mut header = vec![];
-        let mut body = vec![];
-        let mut row_columns = vec![];
-        let mut in_header = true;
-        let column_alignments = alignment
-            .iter()
-            .map(Self::convert_alignment)
-            .collect::<Vec<_>>();
-
-        loop {
-            if self.eof() {
-                break;
-            }
-
-            let (current, source_range) = self.current().unwrap();
-            let source_range = source_range.clone();
-            match current {
-                Event::Start(Tag::TableHead)
-                | Event::Start(Tag::TableRow)
-                | Event::End(TagEnd::TableCell) => {
-                    self.cursor += 1;
-                }
-                Event::Start(Tag::TableCell) => {
-                    self.cursor += 1;
-                    let cell_contents = self.parse_text(false, Some(source_range));
-                    row_columns.push(ParsedMarkdownTableColumn {
-                        col_span: 1,
-                        row_span: 1,
-                        is_header: in_header,
-                        children: cell_contents,
-                        alignment: column_alignments
-                            .get(row_columns.len())
-                            .copied()
-                            .unwrap_or_default(),
-                    });
-                }
-                Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => {
-                    self.cursor += 1;
-                    let columns = std::mem::take(&mut row_columns);
-                    if in_header {
-                        header.push(ParsedMarkdownTableRow { columns: columns });
-                        in_header = false;
-                    } else {
-                        body.push(ParsedMarkdownTableRow::with_columns(columns));
-                    }
-                }
-                Event::End(TagEnd::Table) => {
-                    self.cursor += 1;
-                    break;
-                }
-                _ => {
-                    break;
-                }
-            }
-        }
-
-        ParsedMarkdownTable {
-            source_range,
-            header,
-            body,
-            caption: None,
-        }
-    }
-
-    fn convert_alignment(alignment: &Alignment) -> ParsedMarkdownTableAlignment {
-        match alignment {
-            Alignment::None => ParsedMarkdownTableAlignment::None,
-            Alignment::Left => ParsedMarkdownTableAlignment::Left,
-            Alignment::Center => ParsedMarkdownTableAlignment::Center,
-            Alignment::Right => ParsedMarkdownTableAlignment::Right,
-        }
-    }
-
-    async fn parse_list(&mut self, order: Option<u64>) -> Vec<ParsedMarkdownElement> {
-        let (_, list_source_range) = self.previous().unwrap();
-
-        let mut items = Vec::new();
-        let mut items_stack = vec![MarkdownListItem::default()];
-        let mut depth = 1;
-        let mut order = order;
-        let mut order_stack = Vec::new();
-
-        let mut insertion_indices = FxHashMap::default();
-        let mut source_ranges = FxHashMap::default();
-        let mut start_item_range = list_source_range.clone();
-
-        while !self.eof() {
-            let (current, source_range) = self.current().unwrap();
-            match current {
-                Event::Start(Tag::List(new_order)) => {
-                    if items_stack.last().is_some() && !insertion_indices.contains_key(&depth) {
-                        insertion_indices.insert(depth, items.len());
-                    }
-
-                    // We will use the start of the nested list as the end for the current item's range,
-                    // because we don't care about the hierarchy of list items
-                    if let collections::hash_map::Entry::Vacant(e) = source_ranges.entry(depth) {
-                        e.insert(start_item_range.start..source_range.start);
-                    }
-
-                    order_stack.push(order);
-                    order = *new_order;
-                    self.cursor += 1;
-                    depth += 1;
-                }
-                Event::End(TagEnd::List(_)) => {
-                    order = order_stack.pop().flatten();
-                    self.cursor += 1;
-                    depth -= 1;
-
-                    if depth == 0 {
-                        break;
-                    }
-                }
-                Event::Start(Tag::Item) => {
-                    start_item_range = source_range.clone();
-
-                    self.cursor += 1;
-                    items_stack.push(MarkdownListItem::default());
-
-                    let mut task_list = None;
-                    // Check for task list marker (`- [ ]` or `- [x]`)
-                    if let Some(event) = self.current_event() {
-                        // If there is a linebreak in between two list items the task list marker will actually be the first element of the paragraph
-                        if event == &Event::Start(Tag::Paragraph) {
-                            self.cursor += 1;
-                        }
-
-                        if let Some((Event::TaskListMarker(checked), range)) = self.current() {
-                            task_list = Some((*checked, range.clone()));
-                            self.cursor += 1;
-                        }
-                    }
-
-                    if let Some((event, range)) = self.current() {
-                        // This is a plain list item.
-                        // For example `- some text` or `1. [Docs](./docs.md)`
-                        if MarkdownParser::is_text_like(event) {
-                            let text = self.parse_text(false, Some(range.clone()));
-                            let block = ParsedMarkdownElement::Paragraph(text);
-                            if let Some(content) = items_stack.last_mut() {
-                                let item_type = if let Some((checked, range)) = task_list {
-                                    ParsedMarkdownListItemType::Task(checked, range)
-                                } else if let Some(order) = order {
-                                    ParsedMarkdownListItemType::Ordered(order)
-                                } else {
-                                    ParsedMarkdownListItemType::Unordered
-                                };
-                                content.item_type = item_type;
-                                content.content.push(block);
-                            }
-                        } else {
-                            let block = self.parse_block().await;
-                            if let Some(block) = block
-                                && let Some(list_item) = items_stack.last_mut()
-                            {
-                                list_item.content.extend(block);
-                            }
-                        }
-                    }
-
-                    // If there is a linebreak in between two list items the task list marker will actually be the first element of the paragraph
-                    if self.current_event() == Some(&Event::End(TagEnd::Paragraph)) {
-                        self.cursor += 1;
-                    }
-                }
-                Event::End(TagEnd::Item) => {
-                    self.cursor += 1;
-
-                    if let Some(current) = order {
-                        order = Some(current + 1);
-                    }
-
-                    if let Some(list_item) = items_stack.pop() {
-                        let source_range = source_ranges
-                            .remove(&depth)
-                            .unwrap_or(start_item_range.clone());
-
-                        // We need to remove the last character of the source range, because it includes the newline character
-                        let source_range = source_range.start..source_range.end - 1;
-                        let item = ParsedMarkdownElement::ListItem(ParsedMarkdownListItem {
-                            source_range,
-                            content: list_item.content,
-                            depth,
-                            item_type: list_item.item_type,
-                            nested: false,
-                        });
-
-                        if let Some(index) = insertion_indices.get(&depth) {
-                            items.insert(*index, item);
-                            insertion_indices.remove(&depth);
-                        } else {
-                            items.push(item);
-                        }
-                    }
-                }
-                _ => {
-                    if depth == 0 {
-                        break;
-                    }
-                    // This can only happen if a list item starts with more then one paragraph,
-                    // or the list item contains blocks that should be rendered after the nested list items
-                    let block = self.parse_block().await;
-                    if let Some(block) = block {
-                        if let Some(list_item) = items_stack.last_mut() {
-                            // If we did not insert any nested items yet (in this case insertion index is set), we can append the block to the current list item
-                            if !insertion_indices.contains_key(&depth) {
-                                list_item.content.extend(block);
-                                continue;
-                            }
-                        }
-
-                        // Otherwise we need to insert the block after all the nested items
-                        // that have been parsed so far
-                        items.extend(block);
-                    } else {
-                        self.cursor += 1;
-                    }
-                }
-            }
-        }
-
-        items
-    }
-
-    #[async_recursion]
-    async fn parse_block_quote(&mut self) -> ParsedMarkdownBlockQuote {
-        let (_event, source_range) = self.previous().unwrap();
-        let source_range = source_range.clone();
-        let mut nested_depth = 1;
-
-        let mut children: Vec<ParsedMarkdownElement> = vec![];
-
-        while !self.eof() {
-            let block = self.parse_block().await;
-
-            if let Some(block) = block {
-                children.extend(block);
-            } else {
-                break;
-            }
-
-            if self.eof() {
-                break;
-            }
-
-            let (current, _source_range) = self.current().unwrap();
-            match current {
-                // This is a nested block quote.
-                // Record that we're in a nested block quote and continue parsing.
-                // We don't need to advance the cursor since the next
-                // call to `parse_block` will handle it.
-                Event::Start(Tag::BlockQuote(_kind)) => {
-                    nested_depth += 1;
-                }
-                Event::End(TagEnd::BlockQuote(_kind)) => {
-                    nested_depth -= 1;
-                    if nested_depth == 0 {
-                        self.cursor += 1;
-                        break;
-                    }
-                }
-                _ => {}
-            };
-        }
-
-        ParsedMarkdownBlockQuote {
-            source_range,
-            children,
-        }
-    }
-
-    async fn parse_code_block(
-        &mut self,
-        language: Option<String>,
-    ) -> Option<ParsedMarkdownCodeBlock> {
-        let Some((_event, source_range)) = self.previous() else {
-            return None;
-        };
-
-        let source_range = source_range.clone();
-        let mut code = String::new();
-
-        while !self.eof() {
-            let Some((current, _source_range)) = self.current() else {
-                break;
-            };
-
-            match current {
-                Event::Text(text) => {
-                    code.push_str(text);
-                    self.cursor += 1;
-                }
-                Event::End(TagEnd::CodeBlock) => {
-                    self.cursor += 1;
-                    break;
-                }
-                _ => {
-                    break;
-                }
-            }
-        }
-
-        code = code.strip_suffix('\n').unwrap_or(&code).to_string();
-
-        let highlights = if let Some(language) = &language {
-            if let Some(registry) = &self.language_registry {
-                let rope: language::Rope = code.as_str().into();
-                registry
-                    .language_for_name_or_extension(language)
-                    .await
-                    .map(|l| l.highlight_text(&rope, 0..code.len()))
-                    .ok()
-            } else {
-                None
-            }
-        } else {
-            None
-        };
-
-        Some(ParsedMarkdownCodeBlock {
-            source_range,
-            contents: code.into(),
-            language,
-            highlights,
-        })
-    }
-
-    async fn parse_mermaid_diagram(
-        &mut self,
-        scale: Option<u32>,
-    ) -> Option<ParsedMarkdownMermaidDiagram> {
-        let Some((_event, source_range)) = self.previous() else {
-            return None;
-        };
-
-        let source_range = source_range.clone();
-        let mut code = String::new();
-
-        while !self.eof() {
-            let Some((current, _source_range)) = self.current() else {
-                break;
-            };
-
-            match current {
-                Event::Text(text) => {
-                    code.push_str(text);
-                    self.cursor += 1;
-                }
-                Event::End(TagEnd::CodeBlock) => {
-                    self.cursor += 1;
-                    break;
-                }
-                _ => {
-                    break;
-                }
-            }
-        }
-
-        code = code.strip_suffix('\n').unwrap_or(&code).to_string();
-
-        let scale = scale.unwrap_or(100).clamp(10, 500);
-
-        Some(ParsedMarkdownMermaidDiagram {
-            source_range,
-            contents: ParsedMarkdownMermaidDiagramContents {
-                contents: code.into(),
-                scale,
-            },
-        })
-    }
-
-    async fn parse_html_block(&mut self) -> Vec<ParsedMarkdownElement> {
-        let mut elements = Vec::new();
-        let Some((_event, _source_range)) = self.previous() else {
-            return elements;
-        };
-
-        let mut html_source_range_start = None;
-        let mut html_source_range_end = None;
-        let mut html_buffer = String::new();
-
-        while !self.eof() {
-            let Some((current, source_range)) = self.current() else {
-                break;
-            };
-            let source_range = source_range.clone();
-            match current {
-                Event::Html(html) => {
-                    html_source_range_start.get_or_insert(source_range.start);
-                    html_source_range_end = Some(source_range.end);
-                    html_buffer.push_str(html);
-                    self.cursor += 1;
-                }
-                Event::End(TagEnd::CodeBlock) => {
-                    self.cursor += 1;
-                    break;
-                }
-                _ => {
-                    break;
-                }
-            }
-        }
-
-        let bytes = cleanup_html(&html_buffer);
-
-        let mut cursor = std::io::Cursor::new(bytes);
-        if let Ok(dom) = parse_document(RcDom::default(), ParseOpts::default())
-            .from_utf8()
-            .read_from(&mut cursor)
-            && let Some((start, end)) = html_source_range_start.zip(html_source_range_end)
-        {
-            self.parse_html_node(
-                start..end,
-                &dom.document,
-                &mut elements,
-                &ParseHtmlNodeContext::default(),
-            );
-        }
-
-        elements
-    }
-
-    #[stacksafe]
-    fn parse_html_node(
-        &self,
-        source_range: Range<usize>,
-        node: &Rc<markup5ever_rcdom::Node>,
-        elements: &mut Vec<ParsedMarkdownElement>,
-        context: &ParseHtmlNodeContext,
-    ) {
-        match &node.data {
-            markup5ever_rcdom::NodeData::Document => {
-                self.consume_children(source_range, node, elements, context);
-            }
-            markup5ever_rcdom::NodeData::Text { contents } => {
-                elements.push(ParsedMarkdownElement::Paragraph(vec![
-                    MarkdownParagraphChunk::Text(ParsedMarkdownText {
-                        source_range,
-                        regions: Vec::default(),
-                        highlights: Vec::default(),
-                        contents: contents.borrow().to_string().into(),
-                    }),
-                ]));
-            }
-            markup5ever_rcdom::NodeData::Comment { .. } => {}
-            markup5ever_rcdom::NodeData::Element { name, attrs, .. } => {
-                let mut styles = if let Some(styles) = Self::markdown_style_from_html_styles(
-                    Self::extract_styles_from_attributes(attrs),
-                ) {
-                    vec![MarkdownHighlight::Style(styles)]
-                } else {
-                    Vec::default()
-                };
-
-                if local_name!("img") == name.local {
-                    if let Some(image) = self.extract_image(source_range, attrs) {
-                        elements.push(ParsedMarkdownElement::Image(image));
-                    }
-                } else if local_name!("p") == name.local {
-                    let mut paragraph = MarkdownParagraph::new();
-                    self.parse_paragraph(
-                        source_range,
-                        node,
-                        &mut paragraph,
-                        &mut styles,
-                        &mut Vec::new(),
-                    );
-
-                    if !paragraph.is_empty() {
-                        elements.push(ParsedMarkdownElement::Paragraph(paragraph));
-                    }
-                } else if matches!(
-                    name.local,
-                    local_name!("h1")
-                        | local_name!("h2")
-                        | local_name!("h3")
-                        | local_name!("h4")
-                        | local_name!("h5")
-                        | local_name!("h6")
-                ) {
-                    let mut paragraph = MarkdownParagraph::new();
-                    self.consume_paragraph(
-                        source_range.clone(),
-                        node,
-                        &mut paragraph,
-                        &mut styles,
-                        &mut Vec::new(),
-                    );
-
-                    if !paragraph.is_empty() {
-                        elements.push(ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
-                            source_range,
-                            level: match name.local {
-                                local_name!("h1") => HeadingLevel::H1,
-                                local_name!("h2") => HeadingLevel::H2,
-                                local_name!("h3") => HeadingLevel::H3,
-                                local_name!("h4") => HeadingLevel::H4,
-                                local_name!("h5") => HeadingLevel::H5,
-                                local_name!("h6") => HeadingLevel::H6,
-                                _ => unreachable!(),
-                            },
-                            contents: paragraph,
-                        }));
-                    }
-                } else if local_name!("ul") == name.local || local_name!("ol") == name.local {
-                    if let Some(list_items) = self.extract_html_list(
-                        node,
-                        local_name!("ol") == name.local,
-                        context.list_item_depth,
-                        source_range,
-                    ) {
-                        elements.extend(list_items);
-                    }
-                } else if local_name!("blockquote") == name.local {
-                    if let Some(blockquote) = self.extract_html_blockquote(node, source_range) {
-                        elements.push(ParsedMarkdownElement::BlockQuote(blockquote));
-                    }
-                } else if local_name!("table") == name.local {
-                    if let Some(table) = self.extract_html_table(node, source_range) {
-                        elements.push(ParsedMarkdownElement::Table(table));
-                    }
-                } else {
-                    self.consume_children(source_range, node, elements, context);
-                }
-            }
-            _ => {}
-        }
-    }
-
-    #[stacksafe]
-    fn parse_paragraph(
-        &self,
-        source_range: Range<usize>,
-        node: &Rc<markup5ever_rcdom::Node>,
-        paragraph: &mut MarkdownParagraph,
-        highlights: &mut Vec<MarkdownHighlight>,
-        regions: &mut Vec<(Range<usize>, ParsedRegion)>,
-    ) {
-        fn items_with_range<T>(
-            range: Range<usize>,
-            items: impl IntoIterator<Item = T>,
-        ) -> Vec<(Range<usize>, T)> {
-            items
-                .into_iter()
-                .map(|item| (range.clone(), item))
-                .collect()
-        }
-
-        match &node.data {
-            markup5ever_rcdom::NodeData::Text { contents } => {
-                // append the text to the last chunk, so we can have a hacky version
-                // of inline text with highlighting
-                if let Some(text) = paragraph.iter_mut().last().and_then(|p| match p {
-                    MarkdownParagraphChunk::Text(text) => Some(text),
-                    _ => None,
-                }) {
-                    let mut new_text = text.contents.to_string();
-                    new_text.push_str(&contents.borrow());
-
-                    text.highlights.extend(items_with_range(
-                        text.contents.len()..new_text.len(),
-                        std::mem::take(highlights),
-                    ));
-                    text.regions.extend(items_with_range(
-                        text.contents.len()..new_text.len(),
-                        std::mem::take(regions)
-                            .into_iter()
-                            .map(|(_, region)| region),
-                    ));
-                    text.contents = SharedString::from(new_text);
-                } else {
-                    let contents = contents.borrow().to_string();
-                    paragraph.push(MarkdownParagraphChunk::Text(ParsedMarkdownText {
-                        source_range,
-                        highlights: items_with_range(0..contents.len(), std::mem::take(highlights)),
-                        regions: items_with_range(
-                            0..contents.len(),
-                            std::mem::take(regions)
-                                .into_iter()
-                                .map(|(_, region)| region),
-                        ),
-                        contents: contents.into(),
-                    }));
-                }
-            }
-            markup5ever_rcdom::NodeData::Element { name, attrs, .. } => {
-                if local_name!("img") == name.local {
-                    if let Some(image) = self.extract_image(source_range, attrs) {
-                        paragraph.push(MarkdownParagraphChunk::Image(image));
-                    }
-                } else if local_name!("b") == name.local || local_name!("strong") == name.local {
-                    highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
-                        weight: FontWeight::BOLD,
-                        ..Default::default()
-                    }));
-
-                    self.consume_paragraph(source_range, node, paragraph, highlights, regions);
-                } else if local_name!("i") == name.local {
-                    highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
-                        italic: true,
-                        ..Default::default()
-                    }));
-
-                    self.consume_paragraph(source_range, node, paragraph, highlights, regions);
-                } else if local_name!("em") == name.local {
-                    highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
-                        oblique: true,
-                        ..Default::default()
-                    }));
-
-                    self.consume_paragraph(source_range, node, paragraph, highlights, regions);
-                } else if local_name!("del") == name.local {
-                    highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
-                        strikethrough: true,
-                        ..Default::default()
-                    }));
-
-                    self.consume_paragraph(source_range, node, paragraph, highlights, regions);
-                } else if local_name!("ins") == name.local {
-                    highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
-                        underline: true,
-                        ..Default::default()
-                    }));
-
-                    self.consume_paragraph(source_range, node, paragraph, highlights, regions);
-                } else if local_name!("a") == name.local {
-                    if let Some(url) = Self::attr_value(attrs, local_name!("href"))
-                        && let Some(link) =
-                            Link::identify(self.file_location_directory.clone(), url)
-                    {
-                        highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle {
-                            link: true,
-                            ..Default::default()
-                        }));
-
-                        regions.push((
-                            source_range.clone(),
-                            ParsedRegion {
-                                code: false,
-                                link: Some(link),
-                            },
-                        ));
-                    }
-
-                    self.consume_paragraph(source_range, node, paragraph, highlights, regions);
-                } else {
-                    self.consume_paragraph(source_range, node, paragraph, highlights, regions);
-                }
-            }
-            _ => {}
-        }
-    }
-
-    fn consume_paragraph(
-        &self,
-        source_range: Range<usize>,
-        node: &Rc<markup5ever_rcdom::Node>,
-        paragraph: &mut MarkdownParagraph,
-        highlights: &mut Vec<MarkdownHighlight>,
-        regions: &mut Vec<(Range<usize>, ParsedRegion)>,
-    ) {
-        for node in node.children.borrow().iter() {
-            self.parse_paragraph(source_range.clone(), node, paragraph, highlights, regions);
-        }
-    }
-
-    fn parse_table_row(
-        &self,
-        source_range: Range<usize>,
-        node: &Rc<markup5ever_rcdom::Node>,
-    ) -> Option<ParsedMarkdownTableRow> {
-        let mut columns = Vec::new();
-
-        match &node.data {
-            markup5ever_rcdom::NodeData::Element { name, .. } => {
-                if local_name!("tr") != name.local {
-                    return None;
-                }
-
-                for node in node.children.borrow().iter() {
-                    if let Some(column) = self.parse_table_column(source_range.clone(), node) {
-                        columns.push(column);
-                    }
-                }
-            }
-            _ => {}
-        }
-
-        if columns.is_empty() {
-            None
-        } else {
-            Some(ParsedMarkdownTableRow { columns })
-        }
-    }
-
-    fn parse_table_column(
-        &self,
-        source_range: Range<usize>,
-        node: &Rc<markup5ever_rcdom::Node>,
-    ) -> Option<ParsedMarkdownTableColumn> {
-        match &node.data {
-            markup5ever_rcdom::NodeData::Element { name, attrs, .. } => {
-                if !matches!(name.local, local_name!("th") | local_name!("td")) {
-                    return None;
-                }
-
-                let mut children = MarkdownParagraph::new();
-                self.consume_paragraph(
-                    source_range,
-                    node,
-                    &mut children,
-                    &mut Vec::new(),
-                    &mut Vec::new(),
-                );
-
-                let is_header = matches!(name.local, local_name!("th"));
-
-                Some(ParsedMarkdownTableColumn {
-                    col_span: std::cmp::max(
-                        Self::attr_value(attrs, local_name!("colspan"))
-                            .and_then(|span| span.parse().ok())
-                            .unwrap_or(1),
-                        1,
-                    ),
-                    row_span: std::cmp::max(
-                        Self::attr_value(attrs, local_name!("rowspan"))
-                            .and_then(|span| span.parse().ok())
-                            .unwrap_or(1),
-                        1,
-                    ),
-                    is_header,
-                    children,
-                    alignment: Self::attr_value(attrs, local_name!("align"))
-                        .and_then(|align| match align.as_str() {
-                            "left" => Some(ParsedMarkdownTableAlignment::Left),
-                            "center" => Some(ParsedMarkdownTableAlignment::Center),
-                            "right" => Some(ParsedMarkdownTableAlignment::Right),
-                            _ => None,
-                        })
-                        .unwrap_or_else(|| {
-                            if is_header {
-                                ParsedMarkdownTableAlignment::Center
-                            } else {
-                                ParsedMarkdownTableAlignment::default()
-                            }
-                        }),
-                })
-            }
-            _ => None,
-        }
-    }
-
-    fn consume_children(
-        &self,
-        source_range: Range<usize>,
-        node: &Rc<markup5ever_rcdom::Node>,
-        elements: &mut Vec<ParsedMarkdownElement>,
-        context: &ParseHtmlNodeContext,
-    ) {
-        for node in node.children.borrow().iter() {
-            self.parse_html_node(source_range.clone(), node, elements, context);
-        }
-    }
-
-    fn attr_value(
-        attrs: &RefCell<Vec<html5ever::Attribute>>,
-        name: html5ever::LocalName,
-    ) -> Option<String> {
-        attrs.borrow().iter().find_map(|attr| {
-            if attr.name.local == name {
-                Some(attr.value.to_string())
-            } else {
-                None
-            }
-        })
-    }
-
-    fn markdown_style_from_html_styles(
-        styles: HashMap<String, String>,
-    ) -> Option<MarkdownHighlightStyle> {
-        let mut markdown_style = MarkdownHighlightStyle::default();
-
-        if let Some(text_decoration) = styles.get("text-decoration") {
-            match text_decoration.to_lowercase().as_str() {
-                "underline" => {
-                    markdown_style.underline = true;
-                }
-                "line-through" => {
-                    markdown_style.strikethrough = true;
-                }
-                _ => {}
-            }
-        }
-
-        if let Some(font_style) = styles.get("font-style") {
-            match font_style.to_lowercase().as_str() {
-                "italic" => {
-                    markdown_style.italic = true;
-                }
-                "oblique" => {
-                    markdown_style.oblique = true;
-                }
-                _ => {}
-            }
-        }
-
-        if let Some(font_weight) = styles.get("font-weight") {
-            match font_weight.to_lowercase().as_str() {
-                "bold" => {
-                    markdown_style.weight = FontWeight::BOLD;
-                }
-                "lighter" => {
-                    markdown_style.weight = FontWeight::THIN;
-                }
-                _ => {
-                    if let Some(weight) = font_weight.parse::<f32>().ok() {
-                        markdown_style.weight = FontWeight(weight);
-                    }
-                }
-            }
-        }
-
-        if markdown_style != MarkdownHighlightStyle::default() {
-            Some(markdown_style)
-        } else {
-            None
-        }
-    }
-
-    fn extract_styles_from_attributes(
-        attrs: &RefCell<Vec<html5ever::Attribute>>,
-    ) -> HashMap<String, String> {
-        let mut styles = HashMap::new();
-
-        if let Some(style) = Self::attr_value(attrs, local_name!("style")) {
-            for decl in style.split(';') {
-                let mut parts = decl.splitn(2, ':');
-                if let Some((key, value)) = parts.next().zip(parts.next()) {
-                    styles.insert(
-                        key.trim().to_lowercase().to_string(),
-                        value.trim().to_string(),
-                    );
-                }
-            }
-        }
-
-        styles
-    }
-
-    fn extract_image(
-        &self,
-        source_range: Range<usize>,
-        attrs: &RefCell<Vec<html5ever::Attribute>>,
-    ) -> Option<Image> {
-        let src = Self::attr_value(attrs, local_name!("src"))?;
-
-        let mut image = Image::identify(src, source_range, self.file_location_directory.clone())?;
-
-        if let Some(alt) = Self::attr_value(attrs, local_name!("alt")) {
-            image.set_alt_text(alt.into());
-        }
-
-        let styles = Self::extract_styles_from_attributes(attrs);
-
-        if let Some(width) = Self::attr_value(attrs, local_name!("width"))
-            .or_else(|| styles.get("width").cloned())
-            .and_then(|width| Self::parse_html_element_dimension(&width))
-        {
-            image.set_width(width);
-        }
-
-        if let Some(height) = Self::attr_value(attrs, local_name!("height"))
-            .or_else(|| styles.get("height").cloned())
-            .and_then(|height| Self::parse_html_element_dimension(&height))
-        {
-            image.set_height(height);
-        }
-
-        Some(image)
-    }
-
-    fn extract_html_list(
-        &self,
-        node: &Rc<markup5ever_rcdom::Node>,
-        ordered: bool,
-        depth: u16,
-        source_range: Range<usize>,
-    ) -> Option<Vec<ParsedMarkdownElement>> {
-        let mut list_items = Vec::with_capacity(node.children.borrow().len());
-
-        for (index, node) in node.children.borrow().iter().enumerate() {
-            match &node.data {
-                markup5ever_rcdom::NodeData::Element { name, .. } => {
-                    if local_name!("li") != name.local {
-                        continue;
-                    }
-
-                    let mut content = Vec::new();
-                    self.consume_children(
-                        source_range.clone(),
-                        node,
-                        &mut content,
-                        &ParseHtmlNodeContext {
-                            list_item_depth: depth + 1,
-                        },
-                    );
-
-                    if !content.is_empty() {
-                        list_items.push(ParsedMarkdownElement::ListItem(ParsedMarkdownListItem {
-                            depth,
-                            source_range: source_range.clone(),
-                            item_type: if ordered {
-                                ParsedMarkdownListItemType::Ordered(index as u64 + 1)
-                            } else {
-                                ParsedMarkdownListItemType::Unordered
-                            },
-                            content,
-                            nested: true,
-                        }));
-                    }
-                }
-                _ => {}
-            }
-        }
-
-        if list_items.is_empty() {
-            None
-        } else {
-            Some(list_items)
-        }
-    }
-
-    fn parse_html_element_dimension(value: &str) -> Option<DefiniteLength> {
-        if value.ends_with("%") {
-            value
-                .trim_end_matches("%")
-                .parse::<f32>()
-                .ok()
-                .map(|value| relative(value / 100.))
-        } else {
-            value
-                .trim_end_matches("px")
-                .parse()
-                .ok()
-                .map(|value| px(value).into())
-        }
-    }
-
-    fn extract_html_blockquote(
-        &self,
-        node: &Rc<markup5ever_rcdom::Node>,
-        source_range: Range<usize>,
-    ) -> Option<ParsedMarkdownBlockQuote> {
-        let mut children = Vec::new();
-        self.consume_children(
-            source_range.clone(),
-            node,
-            &mut children,
-            &ParseHtmlNodeContext::default(),
-        );
-
-        if children.is_empty() {
-            None
-        } else {
-            Some(ParsedMarkdownBlockQuote {
-                children,
-                source_range,
-            })
-        }
-    }
-
-    fn extract_html_table(
-        &self,
-        node: &Rc<markup5ever_rcdom::Node>,
-        source_range: Range<usize>,
-    ) -> Option<ParsedMarkdownTable> {
-        let mut header_rows = Vec::new();
-        let mut body_rows = Vec::new();
-        let mut caption = None;
-
-        // node should be a thead, tbody or caption element
-        for node in node.children.borrow().iter() {
-            match &node.data {
-                markup5ever_rcdom::NodeData::Element { name, .. } => {
-                    if local_name!("caption") == name.local {
-                        let mut paragraph = MarkdownParagraph::new();
-                        self.parse_paragraph(
-                            source_range.clone(),
-                            node,
-                            &mut paragraph,
-                            &mut Vec::new(),
-                            &mut Vec::new(),
-                        );
-                        caption = Some(paragraph);
-                    }
-                    if local_name!("thead") == name.local {
-                        // node should be a tr element
-                        for node in node.children.borrow().iter() {
-                            if let Some(row) = self.parse_table_row(source_range.clone(), node) {
-                                header_rows.push(row);
-                            }
-                        }
-                    } else if local_name!("tbody") == name.local {
-                        // node should be a tr element
-                        for node in node.children.borrow().iter() {
-                            if let Some(row) = self.parse_table_row(source_range.clone(), node) {
-                                body_rows.push(row);
-                            }
-                        }
-                    }
-                }
-                _ => {}
-            }
-        }
-
-        if !header_rows.is_empty() || !body_rows.is_empty() {
-            Some(ParsedMarkdownTable {
-                source_range,
-                body: body_rows,
-                header: header_rows,
-                caption,
-            })
-        } else {
-            None
-        }
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use ParsedMarkdownListItemType::*;
-    use core::panic;
-    use gpui::{AbsoluteLength, BackgroundExecutor, DefiniteLength};
-    use language::{HighlightId, LanguageRegistry};
-    use pretty_assertions::assert_eq;
-
-    async fn parse(input: &str) -> ParsedMarkdown {
-        parse_markdown(input, None, None).await
-    }
-
-    #[gpui::test]
-    async fn test_headings() {
-        let parsed = parse("# Heading one\n## Heading two\n### Heading three").await;
-
-        assert_eq!(
-            parsed.children,
-            vec![
-                h1(text("Heading one", 2..13), 0..14),
-                h2(text("Heading two", 17..28), 14..29),
-                h3(text("Heading three", 33..46), 29..46),
-            ]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_newlines_dont_new_paragraphs() {
-        let parsed = parse("Some text **that is bolded**\n and *italicized*").await;
-
-        assert_eq!(
-            parsed.children,
-            vec![p("Some text that is bolded and italicized", 0..46)]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_heading_with_paragraph() {
-        let parsed = parse("# Zed\nThe editor").await;
-
-        assert_eq!(
-            parsed.children,
-            vec![h1(text("Zed", 2..5), 0..6), p("The editor", 6..16),]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_double_newlines_do_new_paragraphs() {
-        let parsed = parse("Some text **that is bolded**\n\n and *italicized*").await;
-
-        assert_eq!(
-            parsed.children,
-            vec![
-                p("Some text that is bolded", 0..29),
-                p("and italicized", 31..47),
-            ]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_bold_italic_text() {
-        let parsed = parse("Some text **that is bolded** and *italicized*").await;
-
-        assert_eq!(
-            parsed.children,
-            vec![p("Some text that is bolded and italicized", 0..45)]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_nested_bold_strikethrough_text() {
-        let parsed = parse("Some **bo~~strikethrough~~ld** text").await;
-
-        assert_eq!(parsed.children.len(), 1);
-        assert_eq!(
-            parsed.children[0],
-            ParsedMarkdownElement::Paragraph(vec![MarkdownParagraphChunk::Text(
-                ParsedMarkdownText {
-                    source_range: 0..35,
-                    contents: "Some bostrikethroughld text".into(),
-                    highlights: Vec::new(),
-                    regions: Vec::new(),
-                }
-            )])
-        );
-
-        let new_text = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] {
-            text
-        } else {
-            panic!("Expected a paragraph");
-        };
-
-        let paragraph = if let MarkdownParagraphChunk::Text(text) = &new_text[0] {
-            text
-        } else {
-            panic!("Expected a text");
-        };
-
-        assert_eq!(
-            paragraph.highlights,
-            vec![
-                (
-                    5..7,
-                    MarkdownHighlight::Style(MarkdownHighlightStyle {
-                        weight: FontWeight::BOLD,
-                        ..Default::default()
-                    }),
-                ),
-                (
-                    7..20,
-                    MarkdownHighlight::Style(MarkdownHighlightStyle {
-                        weight: FontWeight::BOLD,
-                        strikethrough: true,
-                        ..Default::default()
-                    }),
-                ),
-                (
-                    20..22,
-                    MarkdownHighlight::Style(MarkdownHighlightStyle {
-                        weight: FontWeight::BOLD,
-                        ..Default::default()
-                    }),
-                ),
-            ]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_html_inline_style_elements() {
-        let parsed =
-                parse("<p>Some text <strong>strong text</strong> more text <b>bold text</b> more text <i>italic text</i> more text <em>emphasized text</em> more text <del>deleted text</del> more text <ins>inserted text</ins></p>").await;
-
-        assert_eq!(1, parsed.children.len());
-        let chunks = if let ParsedMarkdownElement::Paragraph(chunks) = &parsed.children[0] {
-            chunks
-        } else {
-            panic!("Expected a paragraph");
-        };
-
-        assert_eq!(1, chunks.len());
-        let text = if let MarkdownParagraphChunk::Text(text) = &chunks[0] {
-            text
-        } else {
-            panic!("Expected a paragraph");
-        };
-
-        assert_eq!(0..205, text.source_range);
-        assert_eq!(
-            "Some text strong text more text bold text more text italic text more text emphasized text more text deleted text more text inserted text",
-            text.contents.as_str(),
-        );
-        assert_eq!(
-            vec![
-                (
-                    10..21,
-                    MarkdownHighlight::Style(MarkdownHighlightStyle {
-                        weight: FontWeight(700.0),
-                        ..Default::default()
-                    },),
-                ),
-                (
-                    32..41,
-                    MarkdownHighlight::Style(MarkdownHighlightStyle {
-                        weight: FontWeight(700.0),
-                        ..Default::default()
-                    },),
-                ),
-                (
-                    52..63,
-                    MarkdownHighlight::Style(MarkdownHighlightStyle {
-                        italic: true,
-                        weight: FontWeight(400.0),
-                        ..Default::default()
-                    },),
-                ),
-                (
-                    74..89,
-                    MarkdownHighlight::Style(MarkdownHighlightStyle {
-                        weight: FontWeight(400.0),
-                        oblique: true,
-                        ..Default::default()
-                    },),
-                ),
-                (
-                    100..112,
-                    MarkdownHighlight::Style(MarkdownHighlightStyle {
-                        strikethrough: true,
-                        weight: FontWeight(400.0),
-                        ..Default::default()
-                    },),
-                ),
-                (
-                    123..136,
-                    MarkdownHighlight::Style(MarkdownHighlightStyle {
-                        underline: true,
-                        weight: FontWeight(400.0,),
-                        ..Default::default()
-                    },),
-                ),
-            ],
-            text.highlights
-        );
-    }
-
-    #[gpui::test]
-    async fn test_html_href_element() {
-        let parsed =
-            parse("<p>Some text <a href=\"https://example.com\">link</a> more text</p>").await;
-
-        assert_eq!(1, parsed.children.len());
-        let chunks = if let ParsedMarkdownElement::Paragraph(chunks) = &parsed.children[0] {
-            chunks
-        } else {
-            panic!("Expected a paragraph");
-        };
-
-        assert_eq!(1, chunks.len());
-        let text = if let MarkdownParagraphChunk::Text(text) = &chunks[0] {
-            text
-        } else {
-            panic!("Expected a paragraph");
-        };
-
-        assert_eq!(0..65, text.source_range);
-        assert_eq!("Some text link more text", text.contents.as_str(),);
-        assert_eq!(
-            vec![(
-                10..14,
-                MarkdownHighlight::Style(MarkdownHighlightStyle {
-                    link: true,
-                    ..Default::default()
-                },),
-            )],
-            text.highlights
-        );
-        assert_eq!(
-            vec![(
-                10..14,
-                ParsedRegion {
-                    code: false,
-                    link: Some(Link::Web {
-                        url: "https://example.com".into()
-                    })
-                }
-            )],
-            text.regions
-        )
-    }
-
-    #[gpui::test]
-    async fn test_text_with_inline_html() {
-        let parsed = parse("This is a paragraph with an inline HTML <sometag>tag</sometag>.").await;
-
-        assert_eq!(
-            parsed.children,
-            vec![p("This is a paragraph with an inline HTML tag.", 0..63),],
-        );
-    }
-
-    #[gpui::test]
-    async fn test_raw_links_detection() {
-        let parsed = parse("Checkout this https://zed.dev link").await;
-
-        assert_eq!(
-            parsed.children,
-            vec![p("Checkout this https://zed.dev link", 0..34)]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_empty_image() {
-        let parsed = parse("![]()").await;
-
-        let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] {
-            text
-        } else {
-            panic!("Expected a paragraph");
-        };
-        assert_eq!(paragraph.len(), 0);
-    }
-
-    #[gpui::test]
-    async fn test_image_links_detection() {
-        let parsed = parse("![test](https://blog.logrocket.com/wp-content/uploads/2024/04/exploring-zed-open-source-code-editor-rust-2.png)").await;
-
-        let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] {
-            text
-        } else {
-            panic!("Expected a paragraph");
-        };
-        assert_eq!(
-                paragraph[0],
-                MarkdownParagraphChunk::Image(Image {
-                    source_range: 0..111,
-                    link: Link::Web {
-                        url: "https://blog.logrocket.com/wp-content/uploads/2024/04/exploring-zed-open-source-code-editor-rust-2.png".to_string(),
-                    },
-                    alt_text: Some("test".into()),
-                    height: None,
-                    width: None,
-                },)
-            );
-    }
-
-    #[gpui::test]
-    async fn test_image_alt_text() {
-        let parsed = parse("[![Zed](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/zed-industries/zed/main/assets/badge/v0.json)](https://zed.dev)\n ").await;
-
-        let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] {
-            text
-        } else {
-            panic!("Expected a paragraph");
-        };
-        assert_eq!(
-                    paragraph[0],
-                    MarkdownParagraphChunk::Image(Image {
-                        source_range: 0..142,
-                        link: Link::Web {
-                            url: "https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/zed-industries/zed/main/assets/badge/v0.json".to_string(),
-                        },
-                        alt_text: Some("Zed".into()),
-                        height: None,
-                        width: None,
-                    },)
-                );
-    }
-
-    #[gpui::test]
-    async fn test_image_without_alt_text() {
-        let parsed = parse("![](http://example.com/foo.png)").await;
-
-        let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] {
-            text
-        } else {
-            panic!("Expected a paragraph");
-        };
-        assert_eq!(
-            paragraph[0],
-            MarkdownParagraphChunk::Image(Image {
-                source_range: 0..31,
-                link: Link::Web {
-                    url: "http://example.com/foo.png".to_string(),
-                },
-                alt_text: None,
-                height: None,
-                width: None,
-            },)
-        );
-    }
-
-    #[gpui::test]
-    async fn test_image_with_alt_text_containing_formatting() {
-        let parsed = parse("![foo *bar* baz](http://example.com/foo.png)").await;
-
-        let ParsedMarkdownElement::Paragraph(chunks) = &parsed.children[0] else {
-            panic!("Expected a paragraph");
-        };
-        assert_eq!(
-            chunks,
-            &[MarkdownParagraphChunk::Image(Image {
-                source_range: 0..44,
-                link: Link::Web {
-                    url: "http://example.com/foo.png".to_string(),
-                },
-                alt_text: Some("foo bar baz".into()),
-                height: None,
-                width: None,
-            }),],
-        );
-    }
-
-    #[gpui::test]
-    async fn test_images_with_text_in_between() {
-        let parsed = parse(
-            "![foo](http://example.com/foo.png)\nLorem Ipsum\n![bar](http://example.com/bar.png)",
-        )
-        .await;
-
-        let chunks = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] {
-            text
-        } else {
-            panic!("Expected a paragraph");
-        };
-        assert_eq!(
-            chunks,
-            &vec![
-                MarkdownParagraphChunk::Image(Image {
-                    source_range: 0..81,
-                    link: Link::Web {
-                        url: "http://example.com/foo.png".to_string(),
-                    },
-                    alt_text: Some("foo".into()),
-                    height: None,
-                    width: None,
-                }),
-                MarkdownParagraphChunk::Text(ParsedMarkdownText {
-                    source_range: 0..81,
-                    contents: " Lorem Ipsum ".into(),
-                    highlights: Vec::new(),
-                    regions: Vec::new(),
-                }),
-                MarkdownParagraphChunk::Image(Image {
-                    source_range: 0..81,
-                    link: Link::Web {
-                        url: "http://example.com/bar.png".to_string(),
-                    },
-                    alt_text: Some("bar".into()),
-                    height: None,
-                    width: None,
-                })
-            ]
-        );
-    }
-
-    #[test]
-    fn test_parse_html_element_dimension() {
-        // Test percentage values
-        assert_eq!(
-            MarkdownParser::parse_html_element_dimension("50%"),
-            Some(DefiniteLength::Fraction(0.5))
-        );
-        assert_eq!(
-            MarkdownParser::parse_html_element_dimension("100%"),
-            Some(DefiniteLength::Fraction(1.0))
-        );
-        assert_eq!(
-            MarkdownParser::parse_html_element_dimension("25%"),
-            Some(DefiniteLength::Fraction(0.25))
-        );
-        assert_eq!(
-            MarkdownParser::parse_html_element_dimension("0%"),
-            Some(DefiniteLength::Fraction(0.0))
-        );
-
-        // Test pixel values
-        assert_eq!(
-            MarkdownParser::parse_html_element_dimension("100px"),
-            Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.0))))
-        );
-        assert_eq!(
-            MarkdownParser::parse_html_element_dimension("50px"),
-            Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(50.0))))
-        );
-        assert_eq!(
-            MarkdownParser::parse_html_element_dimension("0px"),
-            Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(0.0))))
-        );
-
-        // Test values without units (should be treated as pixels)
-        assert_eq!(
-            MarkdownParser::parse_html_element_dimension("100"),
-            Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.0))))
-        );
-        assert_eq!(
-            MarkdownParser::parse_html_element_dimension("42"),
-            Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(42.0))))
-        );
-
-        // Test invalid values
-        assert_eq!(
-            MarkdownParser::parse_html_element_dimension("invalid"),
-            None
-        );
-        assert_eq!(MarkdownParser::parse_html_element_dimension("px"), None);
-        assert_eq!(MarkdownParser::parse_html_element_dimension("%"), None);
-        assert_eq!(MarkdownParser::parse_html_element_dimension(""), None);
-        assert_eq!(MarkdownParser::parse_html_element_dimension("abc%"), None);
-        assert_eq!(MarkdownParser::parse_html_element_dimension("abcpx"), None);
-
-        // Test decimal values
-        assert_eq!(
-            MarkdownParser::parse_html_element_dimension("50.5%"),
-            Some(DefiniteLength::Fraction(0.505))
-        );
-        assert_eq!(
-            MarkdownParser::parse_html_element_dimension("100.25px"),
-            Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.25))))
-        );
-        assert_eq!(
-            MarkdownParser::parse_html_element_dimension("42.0"),
-            Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(42.0))))
-        );
-    }
-
-    #[gpui::test]
-    async fn test_html_unordered_list() {
-        let parsed = parse(
-            "<ul>
-              <li>Item 1</li>
-              <li>Item 2</li>
-            </ul>",
-        )
-        .await;
-
-        assert_eq!(
-            ParsedMarkdown {
-                children: vec![
-                    nested_list_item(
-                        0..82,
-                        1,
-                        ParsedMarkdownListItemType::Unordered,
-                        vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..82))]
-                    ),
-                    nested_list_item(
-                        0..82,
-                        1,
-                        ParsedMarkdownListItemType::Unordered,
-                        vec![ParsedMarkdownElement::Paragraph(text("Item 2", 0..82))]
-                    ),
-                ]
-            },
-            parsed
-        );
-    }
-
-    #[gpui::test]
-    async fn test_html_ordered_list() {
-        let parsed = parse(
-            "<ol>
-              <li>Item 1</li>
-              <li>Item 2</li>
-            </ol>",
-        )
-        .await;
-
-        assert_eq!(
-            ParsedMarkdown {
-                children: vec![
-                    nested_list_item(
-                        0..82,
-                        1,
-                        ParsedMarkdownListItemType::Ordered(1),
-                        vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..82))]
-                    ),
-                    nested_list_item(
-                        0..82,
-                        1,
-                        ParsedMarkdownListItemType::Ordered(2),
-                        vec![ParsedMarkdownElement::Paragraph(text("Item 2", 0..82))]
-                    ),
-                ]
-            },
-            parsed
-        );
-    }
-
-    #[gpui::test]
-    async fn test_html_nested_ordered_list() {
-        let parsed = parse(
-            "<ol>
-              <li>Item 1</li>
-              <li>Item 2
-                <ol>
-                  <li>Sub-Item 1</li>
-                  <li>Sub-Item 2</li>
-                </ol>
-              </li>
-            </ol>",
-        )
-        .await;
-
-        assert_eq!(
-            ParsedMarkdown {
-                children: vec![
-                    nested_list_item(
-                        0..216,
-                        1,
-                        ParsedMarkdownListItemType::Ordered(1),
-                        vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..216))]
-                    ),
-                    nested_list_item(
-                        0..216,
-                        1,
-                        ParsedMarkdownListItemType::Ordered(2),
-                        vec![
-                            ParsedMarkdownElement::Paragraph(text("Item 2", 0..216)),
-                            nested_list_item(
-                                0..216,
-                                2,
-                                ParsedMarkdownListItemType::Ordered(1),
-                                vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 1", 0..216))]
-                            ),
-                            nested_list_item(
-                                0..216,
-                                2,
-                                ParsedMarkdownListItemType::Ordered(2),
-                                vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 2", 0..216))]
-                            ),
-                        ]
-                    ),
-                ]
-            },
-            parsed
-        );
-    }
-
-    #[gpui::test]
-    async fn test_html_nested_unordered_list() {
-        let parsed = parse(
-            "<ul>
-              <li>Item 1</li>
-              <li>Item 2
-                <ul>
-                  <li>Sub-Item 1</li>
-                  <li>Sub-Item 2</li>
-                </ul>
-              </li>
-            </ul>",
-        )
-        .await;
-
-        assert_eq!(
-            ParsedMarkdown {
-                children: vec![
-                    nested_list_item(
-                        0..216,
-                        1,
-                        ParsedMarkdownListItemType::Unordered,
-                        vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..216))]
-                    ),
-                    nested_list_item(
-                        0..216,
-                        1,
-                        ParsedMarkdownListItemType::Unordered,
-                        vec![
-                            ParsedMarkdownElement::Paragraph(text("Item 2", 0..216)),
-                            nested_list_item(
-                                0..216,
-                                2,
-                                ParsedMarkdownListItemType::Unordered,
-                                vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 1", 0..216))]
-                            ),
-                            nested_list_item(
-                                0..216,
-                                2,
-                                ParsedMarkdownListItemType::Unordered,
-                                vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 2", 0..216))]
-                            ),
-                        ]
-                    ),
-                ]
-            },
-            parsed
-        );
-    }
-
-    #[gpui::test]
-    async fn test_inline_html_image_tag() {
-        let parsed =
-            parse("<p>Some text<img src=\"http://example.com/foo.png\" /> some more text</p>")
-                .await;
-
-        assert_eq!(
-            ParsedMarkdown {
-                children: vec![ParsedMarkdownElement::Paragraph(vec![
-                    MarkdownParagraphChunk::Text(ParsedMarkdownText {
-                        source_range: 0..71,
-                        contents: "Some text".into(),
-                        highlights: Default::default(),
-                        regions: Default::default()
-                    }),
-                    MarkdownParagraphChunk::Image(Image {
-                        source_range: 0..71,
-                        link: Link::Web {
-                            url: "http://example.com/foo.png".to_string(),
-                        },
-                        alt_text: None,
-                        height: None,
-                        width: None,
-                    }),
-                    MarkdownParagraphChunk::Text(ParsedMarkdownText {
-                        source_range: 0..71,
-                        contents: " some more text".into(),
-                        highlights: Default::default(),
-                        regions: Default::default()
-                    }),
-                ])]
-            },
-            parsed
-        );
-    }
-
-    #[gpui::test]
-    async fn test_html_block_quote() {
-        let parsed = parse(
-            "<blockquote>
-                <p>some description</p>
-            </blockquote>",
-        )
-        .await;
-
-        assert_eq!(
-            ParsedMarkdown {
-                children: vec![block_quote(
-                    vec![ParsedMarkdownElement::Paragraph(text(
-                        "some description",
-                        0..78
-                    ))],
-                    0..78,
-                )]
-            },
-            parsed
-        );
-    }
-
-    #[gpui::test]
-    async fn test_html_nested_block_quote() {
-        let parsed = parse(
-            "<blockquote>
-                <p>some description</p>
-                <blockquote>
-                <p>second description</p>
-                </blockquote>
-            </blockquote>",
-        )
-        .await;
-
-        assert_eq!(
-            ParsedMarkdown {
-                children: vec![block_quote(
-                    vec![
-                        ParsedMarkdownElement::Paragraph(text("some description", 0..179)),
-                        block_quote(
-                            vec![ParsedMarkdownElement::Paragraph(text(
-                                "second description",
-                                0..179
-                            ))],
-                            0..179,
-                        )
-                    ],
-                    0..179,
-                )]
-            },
-            parsed
-        );
-    }
-
-    #[gpui::test]
-    async fn test_html_table() {
-        let parsed = parse(
-            "<table>
-          <thead>
-            <tr>
-              <th>Id</th>
-              <th>Name</th>
-            </tr>
-          </thead>
-          <tbody>
-            <tr>
-              <td>1</td>
-              <td>Chris</td>
-            </tr>
-            <tr>
-              <td>2</td>
-              <td>Dennis</td>
-            </tr>
-          </tbody>
-        </table>",
-        )
-        .await;
-
-        assert_eq!(
-            ParsedMarkdown {
-                children: vec![ParsedMarkdownElement::Table(table(
-                    0..366,
-                    None,
-                    vec![row(vec![
-                        column(
-                            1,
-                            1,
-                            true,
-                            text("Id", 0..366),
-                            ParsedMarkdownTableAlignment::Center
-                        ),
-                        column(
-                            1,
-                            1,
-                            true,
-                            text("Name ", 0..366),
-                            ParsedMarkdownTableAlignment::Center
-                        )
-                    ])],
-                    vec![
-                        row(vec![
-                            column(
-                                1,
-                                1,
-                                false,
-                                text("1", 0..366),
-                                ParsedMarkdownTableAlignment::None
-                            ),
-                            column(
-                                1,
-                                1,
-                                false,
-                                text("Chris", 0..366),
-                                ParsedMarkdownTableAlignment::None
-                            )
-                        ]),
-                        row(vec![
-                            column(
-                                1,
-                                1,
-                                false,
-                                text("2", 0..366),
-                                ParsedMarkdownTableAlignment::None
-                            ),
-                            column(
-                                1,
-                                1,
-                                false,
-                                text("Dennis", 0..366),
-                                ParsedMarkdownTableAlignment::None
-                            )
-                        ]),
-                    ],
-                ))],
-            },
-            parsed
-        );
-    }
-
-    #[gpui::test]
-    async fn test_html_table_with_caption() {
-        let parsed = parse(
-            "<table>
-            <caption>My Table</caption>
-          <tbody>
-            <tr>
-              <td>1</td>
-              <td>Chris</td>
-            </tr>
-            <tr>
-              <td>2</td>
-              <td>Dennis</td>
-            </tr>
-          </tbody>
-        </table>",
-        )
-        .await;
-
-        assert_eq!(
-            ParsedMarkdown {
-                children: vec![ParsedMarkdownElement::Table(table(
-                    0..280,
-                    Some(vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
-                        source_range: 0..280,
-                        contents: "My Table".into(),
-                        highlights: Default::default(),
-                        regions: Default::default()
-                    })]),
-                    vec![],
-                    vec![
-                        row(vec![
-                            column(
-                                1,
-                                1,
-                                false,
-                                text("1", 0..280),
-                                ParsedMarkdownTableAlignment::None
-                            ),
-                            column(
-                                1,
-                                1,
-                                false,
-                                text("Chris", 0..280),
-                                ParsedMarkdownTableAlignment::None
-                            )
-                        ]),
-                        row(vec![
-                            column(
-                                1,
-                                1,
-                                false,
-                                text("2", 0..280),
-                                ParsedMarkdownTableAlignment::None
-                            ),
-                            column(
-                                1,
-                                1,
-                                false,
-                                text("Dennis", 0..280),
-                                ParsedMarkdownTableAlignment::None
-                            )
-                        ]),
-                    ],
-                ))],
-            },
-            parsed
-        );
-    }
-
-    #[gpui::test]
-    async fn test_html_table_without_headings() {
-        let parsed = parse(
-            "<table>
-          <tbody>
-            <tr>
-              <td>1</td>
-              <td>Chris</td>
-            </tr>
-            <tr>
-              <td>2</td>
-              <td>Dennis</td>
-            </tr>
-          </tbody>
-        </table>",
-        )
-        .await;
-
-        assert_eq!(
-            ParsedMarkdown {
-                children: vec![ParsedMarkdownElement::Table(table(
-                    0..240,
-                    None,
-                    vec![],
-                    vec![
-                        row(vec![
-                            column(
-                                1,
-                                1,
-                                false,
-                                text("1", 0..240),
-                                ParsedMarkdownTableAlignment::None
-                            ),
-                            column(
-                                1,
-                                1,
-                                false,
-                                text("Chris", 0..240),
-                                ParsedMarkdownTableAlignment::None
-                            )
-                        ]),
-                        row(vec![
-                            column(
-                                1,
-                                1,
-                                false,
-                                text("2", 0..240),
-                                ParsedMarkdownTableAlignment::None
-                            ),
-                            column(
-                                1,
-                                1,
-                                false,
-                                text("Dennis", 0..240),
-                                ParsedMarkdownTableAlignment::None
-                            )
-                        ]),
-                    ],
-                ))],
-            },
-            parsed
-        );
-    }
-
-    #[gpui::test]
-    async fn test_html_table_without_body() {
-        let parsed = parse(
-            "<table>
-          <thead>
-            <tr>
-              <th>Id</th>
-              <th>Name</th>
-            </tr>
-          </thead>
-        </table>",
-        )
-        .await;
-
-        assert_eq!(
-            ParsedMarkdown {
-                children: vec![ParsedMarkdownElement::Table(table(
-                    0..150,
-                    None,
-                    vec![row(vec![
-                        column(
-                            1,
-                            1,
-                            true,
-                            text("Id", 0..150),
-                            ParsedMarkdownTableAlignment::Center
-                        ),
-                        column(
-                            1,
-                            1,
-                            true,
-                            text("Name", 0..150),
-                            ParsedMarkdownTableAlignment::Center
-                        )
-                    ])],
-                    vec![],
-                ))],
-            },
-            parsed
-        );
-    }
-
-    #[gpui::test]
-    async fn test_html_heading_tags() {
-        let parsed = parse("<h1>Heading</h1><h2>Heading</h2><h3>Heading</h3><h4>Heading</h4><h5>Heading</h5><h6>Heading</h6>").await;
-
-        assert_eq!(
-            ParsedMarkdown {
-                children: vec![
-                    ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
-                        level: HeadingLevel::H1,
-                        source_range: 0..96,
-                        contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
-                            source_range: 0..96,
-                            contents: "Heading".into(),
-                            highlights: Vec::default(),
-                            regions: Vec::default()
-                        })],
-                    }),
-                    ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
-                        level: HeadingLevel::H2,
-                        source_range: 0..96,
-                        contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
-                            source_range: 0..96,
-                            contents: "Heading".into(),
-                            highlights: Vec::default(),
-                            regions: Vec::default()
-                        })],
-                    }),
-                    ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
-                        level: HeadingLevel::H3,
-                        source_range: 0..96,
-                        contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
-                            source_range: 0..96,
-                            contents: "Heading".into(),
-                            highlights: Vec::default(),
-                            regions: Vec::default()
-                        })],
-                    }),
-                    ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
-                        level: HeadingLevel::H4,
-                        source_range: 0..96,
-                        contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
-                            source_range: 0..96,
-                            contents: "Heading".into(),
-                            highlights: Vec::default(),
-                            regions: Vec::default()
-                        })],
-                    }),
-                    ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
-                        level: HeadingLevel::H5,
-                        source_range: 0..96,
-                        contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
-                            source_range: 0..96,
-                            contents: "Heading".into(),
-                            highlights: Vec::default(),
-                            regions: Vec::default()
-                        })],
-                    }),
-                    ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
-                        level: HeadingLevel::H6,
-                        source_range: 0..96,
-                        contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
-                            source_range: 0..96,
-                            contents: "Heading".into(),
-                            highlights: Vec::default(),
-                            regions: Vec::default()
-                        })],
-                    }),
-                ],
-            },
-            parsed
-        );
-    }
-
-    #[gpui::test]
-    async fn test_html_image_tag() {
-        let parsed = parse("<img src=\"http://example.com/foo.png\" />").await;
-
-        assert_eq!(
-            ParsedMarkdown {
-                children: vec![ParsedMarkdownElement::Image(Image {
-                    source_range: 0..40,
-                    link: Link::Web {
-                        url: "http://example.com/foo.png".to_string(),
-                    },
-                    alt_text: None,
-                    height: None,
-                    width: None,
-                })]
-            },
-            parsed
-        );
-    }
-
-    #[gpui::test]
-    async fn test_html_image_tag_with_alt_text() {
-        let parsed = parse("<img src=\"http://example.com/foo.png\" alt=\"Foo\" />").await;
-
-        assert_eq!(
-            ParsedMarkdown {
-                children: vec![ParsedMarkdownElement::Image(Image {
-                    source_range: 0..50,
-                    link: Link::Web {
-                        url: "http://example.com/foo.png".to_string(),
-                    },
-                    alt_text: Some("Foo".into()),
-                    height: None,
-                    width: None,
-                })]
-            },
-            parsed
-        );
-    }
-
-    #[gpui::test]
-    async fn test_html_image_tag_with_height_and_width() {
-        let parsed =
-            parse("<img src=\"http://example.com/foo.png\" height=\"100\" width=\"200\" />").await;
-
-        assert_eq!(
-            ParsedMarkdown {
-                children: vec![ParsedMarkdownElement::Image(Image {
-                    source_range: 0..65,
-                    link: Link::Web {
-                        url: "http://example.com/foo.png".to_string(),
-                    },
-                    alt_text: None,
-                    height: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.)))),
-                    width: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(200.)))),
-                })]
-            },
-            parsed
-        );
-    }
-
-    #[gpui::test]
-    async fn test_html_image_style_tag_with_height_and_width() {
-        let parsed = parse(
-            "<img src=\"http://example.com/foo.png\" style=\"height:100px; width:200px;\" />",
-        )
-        .await;
-
-        assert_eq!(
-            ParsedMarkdown {
-                children: vec![ParsedMarkdownElement::Image(Image {
-                    source_range: 0..75,
-                    link: Link::Web {
-                        url: "http://example.com/foo.png".to_string(),
-                    },
-                    alt_text: None,
-                    height: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.)))),
-                    width: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(200.)))),
-                })]
-            },
-            parsed
-        );
-    }
-
-    #[gpui::test]
-    async fn test_header_only_table() {
-        let markdown = "\
-| Header 1 | Header 2 |
-|----------|----------|
-
-Some other content
-";
-
-        let expected_table = table(
-            0..48,
-            None,
-            vec![row(vec![
-                column(
-                    1,
-                    1,
-                    true,
-                    text("Header 1", 1..11),
-                    ParsedMarkdownTableAlignment::None,
-                ),
-                column(
-                    1,
-                    1,
-                    true,
-                    text("Header 2", 12..22),
-                    ParsedMarkdownTableAlignment::None,
-                ),
-            ])],
-            vec![],
-        );
-
-        assert_eq!(
-            parse(markdown).await.children[0],
-            ParsedMarkdownElement::Table(expected_table)
-        );
-    }
-
-    #[gpui::test]
-    async fn test_basic_table() {
-        let markdown = "\
-| Header 1 | Header 2 |
-|----------|----------|
-| Cell 1   | Cell 2   |
-| Cell 3   | Cell 4   |";
-
-        let expected_table = table(
-            0..95,
-            None,
-            vec![row(vec![
-                column(
-                    1,
-                    1,
-                    true,
-                    text("Header 1", 1..11),
-                    ParsedMarkdownTableAlignment::None,
-                ),
-                column(
-                    1,
-                    1,
-                    true,
-                    text("Header 2", 12..22),
-                    ParsedMarkdownTableAlignment::None,
-                ),
-            ])],
-            vec![
-                row(vec![
-                    column(
-                        1,
-                        1,
-                        false,
-                        text("Cell 1", 49..59),
-                        ParsedMarkdownTableAlignment::None,
-                    ),
-                    column(
-                        1,
-                        1,
-                        false,
-                        text("Cell 2", 60..70),
-                        ParsedMarkdownTableAlignment::None,
-                    ),
-                ]),
-                row(vec![
-                    column(
-                        1,
-                        1,
-                        false,
-                        text("Cell 3", 73..83),
-                        ParsedMarkdownTableAlignment::None,
-                    ),
-                    column(
-                        1,
-                        1,
-                        false,
-                        text("Cell 4", 84..94),
-                        ParsedMarkdownTableAlignment::None,
-                    ),
-                ]),
-            ],
-        );
-
-        assert_eq!(
-            parse(markdown).await.children[0],
-            ParsedMarkdownElement::Table(expected_table)
-        );
-    }
-
-    #[gpui::test]
-    async fn test_table_with_checkboxes() {
-        let markdown = "\
-| Done | Task    |
-|------|---------|
-| [x]  | Fix bug |
-| [ ]  | Add feature |";
-
-        let parsed = parse(markdown).await;
-        let table = match &parsed.children[0] {
-            ParsedMarkdownElement::Table(table) => table,
-            other => panic!("Expected table, got: {:?}", other),
-        };
-
-        let first_cell = &table.body[0].columns[0];
-        let first_cell_text = match &first_cell.children[0] {
-            MarkdownParagraphChunk::Text(t) => t.contents.to_string(),
-            other => panic!("Expected text chunk, got: {:?}", other),
-        };
-        assert_eq!(first_cell_text.trim(), "[x]");
-
-        let second_cell = &table.body[1].columns[0];
-        let second_cell_text = match &second_cell.children[0] {
-            MarkdownParagraphChunk::Text(t) => t.contents.to_string(),
-            other => panic!("Expected text chunk, got: {:?}", other),
-        };
-        assert_eq!(second_cell_text.trim(), "[ ]");
-    }
-
-    #[gpui::test]
-    async fn test_list_basic() {
-        let parsed = parse(
-            "\
-* Item 1
-* Item 2
-* Item 3
-",
-        )
-        .await;
-
-        assert_eq!(
-            parsed.children,
-            vec![
-                list_item(0..8, 1, Unordered, vec![p("Item 1", 2..8)]),
-                list_item(9..17, 1, Unordered, vec![p("Item 2", 11..17)]),
-                list_item(18..26, 1, Unordered, vec![p("Item 3", 20..26)]),
-            ],
-        );
-    }
-
-    #[gpui::test]
-    async fn test_list_with_tasks() {
-        let parsed = parse(
-            "\
-- [ ] TODO
-- [x] Checked
-",
-        )
-        .await;
-
-        assert_eq!(
-            parsed.children,
-            vec![
-                list_item(0..10, 1, Task(false, 2..5), vec![p("TODO", 6..10)]),
-                list_item(11..24, 1, Task(true, 13..16), vec![p("Checked", 17..24)]),
-            ],
-        );
-    }
-
-    #[gpui::test]
-    async fn test_list_with_indented_task() {
-        let parsed = parse(
-            "\
-- [ ] TODO
-  - [x] Checked
-  - Unordered
-  1. Number 1
-  1. Number 2
-1. Number A
-",
-        )
-        .await;
-
-        assert_eq!(
-            parsed.children,
-            vec![
-                list_item(0..12, 1, Task(false, 2..5), vec![p("TODO", 6..10)]),
-                list_item(13..26, 2, Task(true, 15..18), vec![p("Checked", 19..26)]),
-                list_item(29..40, 2, Unordered, vec![p("Unordered", 31..40)]),
-                list_item(43..54, 2, Ordered(1), vec![p("Number 1", 46..54)]),
-                list_item(57..68, 2, Ordered(2), vec![p("Number 2", 60..68)]),
-                list_item(69..80, 1, Ordered(1), vec![p("Number A", 72..80)]),
-            ],
-        );
-    }
-
-    #[gpui::test]
-    async fn test_list_with_linebreak_is_handled_correctly() {
-        let parsed = parse(
-            "\
-- [ ] Task 1
-
-- [x] Task 2
-",
-        )
-        .await;
-
-        assert_eq!(
-            parsed.children,
-            vec![
-                list_item(0..13, 1, Task(false, 2..5), vec![p("Task 1", 6..12)]),
-                list_item(14..26, 1, Task(true, 16..19), vec![p("Task 2", 20..26)]),
-            ],
-        );
-    }
-
-    #[gpui::test]
-    async fn test_list_nested() {
-        let parsed = parse(
-            "\
-* Item 1
-* Item 2
-* Item 3
-
-1. Hello
-1. Two
-   1. Three
-2. Four
-3. Five
-
-* First
-  1. Hello
-     1. Goodbyte
-        - Inner
-        - Inner
-  2. Goodbyte
-        - Next item empty
-        -
-* Last
-",
-        )
-        .await;
-
-        assert_eq!(
-            parsed.children,
-            vec![
-                list_item(0..8, 1, Unordered, vec![p("Item 1", 2..8)]),
-                list_item(9..17, 1, Unordered, vec![p("Item 2", 11..17)]),
-                list_item(18..27, 1, Unordered, vec![p("Item 3", 20..26)]),
-                list_item(28..36, 1, Ordered(1), vec![p("Hello", 31..36)]),
-                list_item(37..46, 1, Ordered(2), vec![p("Two", 40..43),]),
-                list_item(47..55, 2, Ordered(1), vec![p("Three", 50..55)]),
-                list_item(56..63, 1, Ordered(3), vec![p("Four", 59..63)]),
-                list_item(64..72, 1, Ordered(4), vec![p("Five", 67..71)]),
-                list_item(73..82, 1, Unordered, vec![p("First", 75..80)]),
-                list_item(83..96, 2, Ordered(1), vec![p("Hello", 86..91)]),
-                list_item(97..116, 3, Ordered(1), vec![p("Goodbyte", 100..108)]),
-                list_item(117..124, 4, Unordered, vec![p("Inner", 119..124)]),
-                list_item(133..140, 4, Unordered, vec![p("Inner", 135..140)]),
-                list_item(143..159, 2, Ordered(2), vec![p("Goodbyte", 146..154)]),
-                list_item(160..180, 3, Unordered, vec![p("Next item empty", 165..180)]),
-                list_item(186..190, 3, Unordered, vec![]),
-                list_item(191..197, 1, Unordered, vec![p("Last", 193..197)]),
-            ]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_list_with_nested_content() {
-        let parsed = parse(
-            "\
-*   This is a list item with two paragraphs.
-
-    This is the second paragraph in the list item.
-",
-        )
-        .await;
-
-        assert_eq!(
-            parsed.children,
-            vec![list_item(
-                0..96,
-                1,
-                Unordered,
-                vec![
-                    p("This is a list item with two paragraphs.", 4..44),
-                    p("This is the second paragraph in the list item.", 50..97)
-                ],
-            ),],
-        );
-    }
-
-    #[gpui::test]
-    async fn test_list_item_with_inline_html() {
-        let parsed = parse(
-            "\
-*   This is a list item with an inline HTML <sometag>tag</sometag>.
-",
-        )
-        .await;
-
-        assert_eq!(
-            parsed.children,
-            vec![list_item(
-                0..67,
-                1,
-                Unordered,
-                vec![p("This is a list item with an inline HTML tag.", 4..44),],
-            ),],
-        );
-    }
-
-    #[gpui::test]
-    async fn test_nested_list_with_paragraph_inside() {
-        let parsed = parse(
-            "\
-1. a
-    1. b
-        1. c
-
-    text
-
-    1. d
-",
-        )
-        .await;
-
-        assert_eq!(
-            parsed.children,
-            vec![
-                list_item(0..7, 1, Ordered(1), vec![p("a", 3..4)],),
-                list_item(8..20, 2, Ordered(1), vec![p("b", 12..13),],),
-                list_item(21..27, 3, Ordered(1), vec![p("c", 25..26),],),
-                p("text", 32..37),
-                list_item(41..46, 2, Ordered(1), vec![p("d", 45..46),],),
-            ],
-        );
-    }
-
-    #[gpui::test]
-    async fn test_list_with_leading_text() {
-        let parsed = parse(
-            "\
-* `code`
-* **bold**
-* [link](https://example.com)
-",
-        )
-        .await;
-
-        assert_eq!(
-            parsed.children,
-            vec![
-                list_item(0..8, 1, Unordered, vec![p("code", 2..8)]),
-                list_item(9..19, 1, Unordered, vec![p("bold", 11..19)]),
-                list_item(20..49, 1, Unordered, vec![p("link", 22..49)],),
-            ],
-        );
-    }
-
-    #[gpui::test]
-    async fn test_simple_block_quote() {
-        let parsed = parse("> Simple block quote with **styled text**").await;
-
-        assert_eq!(
-            parsed.children,
-            vec![block_quote(
-                vec![p("Simple block quote with styled text", 2..41)],
-                0..41
-            )]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_simple_block_quote_with_multiple_lines() {
-        let parsed = parse(
-            "\
-> # Heading
-> More
-> text
->
-> More text
-",
-        )
-        .await;
-
-        assert_eq!(
-            parsed.children,
-            vec![block_quote(
-                vec![
-                    h1(text("Heading", 4..11), 2..12),
-                    p("More text", 14..26),
-                    p("More text", 30..40)
-                ],
-                0..40
-            )]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_nested_block_quote() {
-        let parsed = parse(
-            "\
-> A
->
-> > # B
->
-> C
-
-More text
-",
-        )
-        .await;
-
-        assert_eq!(
-            parsed.children,
-            vec![
-                block_quote(
-                    vec![
-                        p("A", 2..4),
-                        block_quote(vec![h1(text("B", 12..13), 10..14)], 8..14),
-                        p("C", 18..20)
-                    ],
-                    0..20
-                ),
-                p("More text", 21..31)
-            ]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_dollar_signs_are_plain_text() {
-        // Dollar signs should be preserved as plain text, not treated as math delimiters.
-        // Regression test for https://github.com/zed-industries/zed/issues/50170
-        let parsed = parse("$100$ per unit").await;
-        assert_eq!(parsed.children, vec![p("$100$ per unit", 0..14)]);
-    }
-
-    #[gpui::test]
-    async fn test_dollar_signs_in_list_items() {
-        let parsed = parse("- $18,000 budget\n- $20,000 budget\n").await;
-        assert_eq!(
-            parsed.children,
-            vec![
-                list_item(0..16, 1, Unordered, vec![p("$18,000 budget", 2..16)]),
-                list_item(17..33, 1, Unordered, vec![p("$20,000 budget", 19..33)]),
-            ]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_code_block() {
-        let parsed = parse(
-            "\
-```
-fn main() {
-    return 0;
-}
-```
-",
-        )
-        .await;
-
-        assert_eq!(
-            parsed.children,
-            vec![code_block(
-                None,
-                "fn main() {\n    return 0;\n}",
-                0..35,
-                None
-            )]
-        );
-    }
-
-    #[gpui::test]
-    async fn test_code_block_with_language(executor: BackgroundExecutor) {
-        let language_registry = Arc::new(LanguageRegistry::test(executor.clone()));
-        language_registry.add(language::rust_lang());
-
-        let parsed = parse_markdown(
-            "\
-```rust
-fn main() {
-    return 0;
-}
-```
-",
-            None,
-            Some(language_registry),
-        )
-        .await;
-
-        assert_eq!(
-            parsed.children,
-            vec![code_block(
-                Some("rust".to_string()),
-                "fn main() {\n    return 0;\n}",
-                0..39,
-                Some(vec![])
-            )]
-        );
-    }
-
-    fn h1(contents: MarkdownParagraph, source_range: Range<usize>) -> ParsedMarkdownElement {
-        ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
-            source_range,
-            level: HeadingLevel::H1,
-            contents,
-        })
-    }
-
-    fn h2(contents: MarkdownParagraph, source_range: Range<usize>) -> ParsedMarkdownElement {
-        ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
-            source_range,
-            level: HeadingLevel::H2,
-            contents,
-        })
-    }
-
-    fn h3(contents: MarkdownParagraph, source_range: Range<usize>) -> ParsedMarkdownElement {
-        ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
-            source_range,
-            level: HeadingLevel::H3,
-            contents,
-        })
-    }
-
-    fn p(contents: &str, source_range: Range<usize>) -> ParsedMarkdownElement {
-        ParsedMarkdownElement::Paragraph(text(contents, source_range))
-    }
-
-    fn text(contents: &str, source_range: Range<usize>) -> MarkdownParagraph {
-        vec![MarkdownParagraphChunk::Text(ParsedMarkdownText {
-            highlights: Vec::new(),
-            regions: Vec::new(),
-            source_range,
-            contents: contents.to_string().into(),
-        })]
-    }
-
-    fn block_quote(
-        children: Vec<ParsedMarkdownElement>,
-        source_range: Range<usize>,
-    ) -> ParsedMarkdownElement {
-        ParsedMarkdownElement::BlockQuote(ParsedMarkdownBlockQuote {
-            source_range,
-            children,
-        })
-    }
-
-    fn code_block(
-        language: Option<String>,
-        code: &str,
-        source_range: Range<usize>,
-        highlights: Option<Vec<(Range<usize>, HighlightId)>>,
-    ) -> ParsedMarkdownElement {
-        ParsedMarkdownElement::CodeBlock(ParsedMarkdownCodeBlock {
-            source_range,
-            language,
-            contents: code.to_string().into(),
-            highlights,
-        })
-    }
-
-    fn list_item(
-        source_range: Range<usize>,
-        depth: u16,
-        item_type: ParsedMarkdownListItemType,
-        content: Vec<ParsedMarkdownElement>,
-    ) -> ParsedMarkdownElement {
-        ParsedMarkdownElement::ListItem(ParsedMarkdownListItem {
-            source_range,
-            item_type,
-            depth,
-            content,
-            nested: false,
-        })
-    }
-
-    fn nested_list_item(
-        source_range: Range<usize>,
-        depth: u16,
-        item_type: ParsedMarkdownListItemType,
-        content: Vec<ParsedMarkdownElement>,
-    ) -> ParsedMarkdownElement {
-        ParsedMarkdownElement::ListItem(ParsedMarkdownListItem {
-            source_range,
-            item_type,
-            depth,
-            content,
-            nested: true,
-        })
-    }
-
-    fn table(
-        source_range: Range<usize>,
-        caption: Option<MarkdownParagraph>,
-        header: Vec<ParsedMarkdownTableRow>,
-        body: Vec<ParsedMarkdownTableRow>,
-    ) -> ParsedMarkdownTable {
-        ParsedMarkdownTable {
-            source_range,
-            header,
-            body,
-            caption,
-        }
-    }
-
-    fn row(columns: Vec<ParsedMarkdownTableColumn>) -> ParsedMarkdownTableRow {
-        ParsedMarkdownTableRow { columns }
-    }
-
-    fn column(
-        col_span: usize,
-        row_span: usize,
-        is_header: bool,
-        children: MarkdownParagraph,
-        alignment: ParsedMarkdownTableAlignment,
-    ) -> ParsedMarkdownTableColumn {
-        ParsedMarkdownTableColumn {
-            col_span,
-            row_span,
-            is_header,
-            children,
-            alignment,
-        }
-    }
-
-    impl PartialEq for ParsedMarkdownTable {
-        fn eq(&self, other: &Self) -> bool {
-            self.source_range == other.source_range
-                && self.header == other.header
-                && self.body == other.body
-        }
-    }
-
-    impl PartialEq for ParsedMarkdownText {
-        fn eq(&self, other: &Self) -> bool {
-            self.source_range == other.source_range && self.contents == other.contents
-        }
-    }
-}

crates/markdown_preview/src/markdown_preview.rs πŸ”—

@@ -1,11 +1,7 @@
 use gpui::{App, actions};
 use workspace::Workspace;
 
-pub mod markdown_elements;
-mod markdown_minifier;
-pub mod markdown_parser;
 pub mod markdown_preview_view;
-pub mod markdown_renderer;
 
 pub use zed_actions::preview::markdown::{OpenPreview, OpenPreviewToTheSide};
 

crates/markdown_preview/src/markdown_preview_view.rs πŸ”—

@@ -1,46 +1,45 @@
 use std::cmp::min;
+use std::path::{Path, PathBuf};
 use std::sync::Arc;
 use std::time::Duration;
-use std::{ops::Range, path::PathBuf};
 
 use anyhow::Result;
 use editor::scroll::Autoscroll;
 use editor::{Editor, EditorEvent, MultiBufferOffset, SelectionEffects};
 use gpui::{
-    App, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
-    IntoElement, IsZero, ListOffset, ListState, ParentElement, Render, RetainAllImageCache, Styled,
-    Subscription, Task, WeakEntity, Window, list,
+    App, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageSource, InteractiveElement,
+    IntoElement, IsZero, Pixels, Render, Resource, RetainAllImageCache, ScrollHandle, SharedString,
+    SharedUri, Subscription, Task, WeakEntity, Window, point,
 };
 use language::LanguageRegistry;
+use markdown::{
+    CodeBlockRenderer, Markdown, MarkdownElement, MarkdownFont, MarkdownOptions, MarkdownStyle,
+};
 use settings::Settings;
 use theme::ThemeSettings;
 use ui::{WithScrollbar, prelude::*};
+use util::normalize_path;
 use workspace::item::{Item, ItemHandle};
-use workspace::{Pane, Workspace};
+use workspace::{OpenOptions, OpenVisible, Pane, Workspace};
 
-use crate::markdown_elements::ParsedMarkdownElement;
-use crate::markdown_renderer::{CheckboxClickedEvent, MermaidState};
 use crate::{
-    OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollPageDown, ScrollPageUp,
-    markdown_elements::ParsedMarkdown,
-    markdown_parser::parse_markdown,
-    markdown_renderer::{RenderContext, render_markdown_block},
+    OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollDown, ScrollDownByItem,
 };
-use crate::{ScrollDown, ScrollDownByItem, ScrollToBottom, ScrollToTop, ScrollUp, ScrollUpByItem};
+use crate::{ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ScrollUp, ScrollUpByItem};
 
 const REPARSE_DEBOUNCE: Duration = Duration::from_millis(200);
 
 pub struct MarkdownPreviewView {
     workspace: WeakEntity<Workspace>,
-    image_cache: Entity<RetainAllImageCache>,
     active_editor: Option<EditorState>,
     focus_handle: FocusHandle,
-    contents: Option<ParsedMarkdown>,
-    selected_block: usize,
-    list_state: ListState,
-    language_registry: Arc<LanguageRegistry>,
-    mermaid_state: MermaidState,
-    parsing_markdown_task: Option<Task<Result<()>>>,
+    markdown: Entity<Markdown>,
+    _markdown_subscription: Subscription,
+    active_source_index: Option<usize>,
+    scroll_handle: ScrollHandle,
+    image_cache: Entity<RetainAllImageCache>,
+    base_directory: Option<PathBuf>,
+    pending_update_task: Option<Task<Result<()>>>,
     mode: MarkdownPreviewMode,
 }
 
@@ -205,19 +204,35 @@ impl MarkdownPreviewView {
         cx: &mut Context<Workspace>,
     ) -> Entity<Self> {
         cx.new(|cx| {
-            let list_state = ListState::new(0, gpui::ListAlignment::Top, px(1000.));
-
+            let markdown = cx.new(|cx| {
+                Markdown::new_with_options(
+                    SharedString::default(),
+                    Some(language_registry),
+                    None,
+                    MarkdownOptions {
+                        parse_html: true,
+                        render_mermaid_diagrams: true,
+                        ..Default::default()
+                    },
+                    cx,
+                )
+            });
             let mut this = Self {
-                selected_block: 0,
                 active_editor: None,
                 focus_handle: cx.focus_handle(),
                 workspace: workspace.clone(),
-                contents: None,
-                list_state,
-                language_registry,
-                mermaid_state: Default::default(),
-                parsing_markdown_task: None,
+                _markdown_subscription: cx.observe(
+                    &markdown,
+                    |this: &mut Self, _: Entity<Markdown>, cx| {
+                        this.sync_active_root_block(cx);
+                    },
+                ),
+                markdown,
+                active_source_index: None,
+                scroll_handle: ScrollHandle::new(),
                 image_cache: RetainAllImageCache::new(cx),
+                base_directory: None,
+                pending_update_task: None,
                 mode,
             };
 
@@ -280,17 +295,16 @@ impl MarkdownPreviewView {
                     | EditorEvent::BufferEdited { .. }
                     | EditorEvent::DirtyChanged
                     | EditorEvent::ExcerptsEdited { .. } => {
-                        this.parse_markdown_from_active_editor(true, window, cx);
+                        this.update_markdown_from_active_editor(true, false, window, cx);
                     }
                     EditorEvent::SelectionsChanged { .. } => {
-                        let selection_range = editor.update(cx, |editor, cx| {
-                            editor
-                                .selections
-                                .last::<MultiBufferOffset>(&editor.display_snapshot(cx))
-                                .range()
-                        });
-                        this.selected_block = this.get_block_index_under_cursor(selection_range);
-                        this.list_state.scroll_to_reveal_item(this.selected_block);
+                        let (selection_start, editor_is_focused) =
+                            editor.update(cx, |editor, cx| {
+                                let index = Self::selected_source_index(editor, cx);
+                                let focused = editor.focus_handle(cx).is_focused(window);
+                                (index, focused)
+                            });
+                        this.sync_preview_to_source_index(selection_start, editor_is_focused, cx);
                         cx.notify();
                     }
                     _ => {}
@@ -298,27 +312,30 @@ impl MarkdownPreviewView {
             },
         );
 
+        self.base_directory = Self::get_folder_for_active_editor(editor.read(cx), cx);
         self.active_editor = Some(EditorState {
             editor,
             _subscription: subscription,
         });
 
-        self.parse_markdown_from_active_editor(false, window, cx);
+        self.update_markdown_from_active_editor(false, true, window, cx);
     }
 
-    fn parse_markdown_from_active_editor(
+    fn update_markdown_from_active_editor(
         &mut self,
         wait_for_debounce: bool,
+        should_reveal: bool,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
         if let Some(state) = &self.active_editor {
             // if there is already a task to update the ui and the current task is also debounced (not high priority), do nothing
-            if wait_for_debounce && self.parsing_markdown_task.is_some() {
+            if wait_for_debounce && self.pending_update_task.is_some() {
                 return;
             }
-            self.parsing_markdown_task = Some(self.parse_markdown_in_background(
+            self.pending_update_task = Some(self.schedule_markdown_update(
                 wait_for_debounce,
+                should_reveal,
                 state.editor.clone(),
                 window,
                 cx,
@@ -326,63 +343,97 @@ impl MarkdownPreviewView {
         }
     }
 
-    fn parse_markdown_in_background(
+    fn schedule_markdown_update(
         &mut self,
         wait_for_debounce: bool,
+        should_reveal_selection: bool,
         editor: Entity<Editor>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<Result<()>> {
-        let language_registry = self.language_registry.clone();
-
         cx.spawn_in(window, async move |view, cx| {
             if wait_for_debounce {
                 // Wait for the user to stop typing
                 cx.background_executor().timer(REPARSE_DEBOUNCE).await;
             }
 
-            let (contents, file_location) = view.update(cx, |_, cx| {
-                let editor = editor.read(cx);
-                let contents = editor.buffer().read(cx).snapshot(cx).text();
-                let file_location = MarkdownPreviewView::get_folder_for_active_editor(editor, cx);
-                (contents, file_location)
-            })?;
+            let editor_clone = editor.clone();
+            let update = view.update(cx, |view, cx| {
+                let is_active_editor = view
+                    .active_editor
+                    .as_ref()
+                    .is_some_and(|active_editor| active_editor.editor == editor_clone);
+                if !is_active_editor {
+                    return None;
+                }
 
-            let parsing_task = cx.background_spawn(async move {
-                parse_markdown(&contents, file_location, Some(language_registry)).await
-            });
-            let contents = parsing_task.await;
+                let (contents, selection_start) = editor_clone.update(cx, |editor, cx| {
+                    let contents = editor.buffer().read(cx).snapshot(cx).text();
+                    let selection_start = Self::selected_source_index(editor, cx);
+                    (contents, selection_start)
+                });
+                Some((SharedString::from(contents), selection_start))
+            })?;
 
             view.update(cx, move |view, cx| {
-                view.mermaid_state.update(&contents, cx);
-                let markdown_blocks_count = contents.children.len();
-                view.contents = Some(contents);
-                let scroll_top = view.list_state.logical_scroll_top();
-                view.list_state.reset(markdown_blocks_count);
-                view.list_state.scroll_to(scroll_top);
-                view.parsing_markdown_task = None;
+                if let Some((contents, selection_start)) = update {
+                    view.markdown.update(cx, |markdown, cx| {
+                        markdown.reset(contents, cx);
+                    });
+                    view.sync_preview_to_source_index(selection_start, should_reveal_selection, cx);
+                }
+                view.pending_update_task = None;
                 cx.notify();
             })
         })
     }
 
-    fn move_cursor_to_block(
-        &self,
-        window: &mut Window,
+    fn selected_source_index(editor: &Editor, cx: &mut App) -> usize {
+        editor
+            .selections
+            .last::<MultiBufferOffset>(&editor.display_snapshot(cx))
+            .range()
+            .start
+            .0
+    }
+
+    fn sync_preview_to_source_index(
+        &mut self,
+        source_index: usize,
+        reveal: bool,
         cx: &mut Context<Self>,
-        selection: Range<MultiBufferOffset>,
     ) {
-        if let Some(state) = &self.active_editor {
-            state.editor.update(cx, |editor, cx| {
-                editor.change_selections(
-                    SelectionEffects::scroll(Autoscroll::center()),
-                    window,
-                    cx,
-                    |selections| selections.select_ranges(vec![selection]),
-                );
-                window.focus(&editor.focus_handle(cx), cx);
-            });
-        }
+        self.active_source_index = Some(source_index);
+        self.sync_active_root_block(cx);
+        self.markdown.update(cx, |markdown, cx| {
+            if reveal {
+                markdown.request_autoscroll_to_source_index(source_index, cx);
+            }
+        });
+    }
+
+    fn sync_active_root_block(&mut self, cx: &mut Context<Self>) {
+        self.markdown.update(cx, |markdown, cx| {
+            markdown.set_active_root_for_source_index(self.active_source_index, cx);
+        });
+    }
+
+    fn move_cursor_to_source_index(
+        editor: &Entity<Editor>,
+        source_index: usize,
+        window: &mut Window,
+        cx: &mut App,
+    ) {
+        editor.update(cx, |editor, cx| {
+            let selection = MultiBufferOffset(source_index)..MultiBufferOffset(source_index);
+            editor.change_selections(
+                SelectionEffects::scroll(Autoscroll::center()),
+                window,
+                cx,
+                |selections| selections.select_ranges(vec![selection]),
+            );
+            window.focus(&editor.focus_handle(cx), cx);
+        });
     }
 
     /// The absolute path of the file that is currently being previewed.
@@ -398,52 +449,24 @@ impl MarkdownPreviewView {
         }
     }
 
-    fn get_block_index_under_cursor(&self, selection_range: Range<MultiBufferOffset>) -> usize {
-        let mut block_index = None;
-        let cursor = selection_range.start.0;
-
-        let mut last_end = 0;
-        if let Some(content) = &self.contents {
-            for (i, block) in content.children.iter().enumerate() {
-                let Some(Range { start, end }) = block.source_range() else {
-                    continue;
-                };
-
-                // Check if the cursor is between the last block and the current block
-                if last_end <= cursor && cursor < start {
-                    block_index = Some(i.saturating_sub(1));
-                    break;
-                }
-
-                if start <= cursor && end >= cursor {
-                    block_index = Some(i);
-                    break;
-                }
-                last_end = end;
-            }
-
-            if block_index.is_none() && last_end < cursor {
-                block_index = Some(content.children.len().saturating_sub(1));
-            }
-        }
-
-        block_index.unwrap_or_default()
+    fn line_scroll_amount(&self, cx: &App) -> Pixels {
+        let settings = ThemeSettings::get_global(cx);
+        settings.buffer_font_size(cx) * settings.buffer_line_height.value()
     }
 
-    fn should_apply_padding_between(
-        current_block: &ParsedMarkdownElement,
-        next_block: Option<&ParsedMarkdownElement>,
-    ) -> bool {
-        !(current_block.is_list_item() && next_block.map(|b| b.is_list_item()).unwrap_or(false))
+    fn scroll_by_amount(&self, distance: Pixels) {
+        let offset = self.scroll_handle.offset();
+        self.scroll_handle
+            .set_offset(point(offset.x, offset.y - distance));
     }
 
     fn scroll_page_up(&mut self, _: &ScrollPageUp, _window: &mut Window, cx: &mut Context<Self>) {
-        let viewport_height = self.list_state.viewport_bounds().size.height;
+        let viewport_height = self.scroll_handle.bounds().size.height;
         if viewport_height.is_zero() {
             return;
         }
 
-        self.list_state.scroll_by(-viewport_height);
+        self.scroll_by_amount(-viewport_height);
         cx.notify();
     }
 
@@ -453,35 +476,49 @@ impl MarkdownPreviewView {
         _window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let viewport_height = self.list_state.viewport_bounds().size.height;
+        let viewport_height = self.scroll_handle.bounds().size.height;
         if viewport_height.is_zero() {
             return;
         }
 
-        self.list_state.scroll_by(viewport_height);
+        self.scroll_by_amount(viewport_height);
         cx.notify();
     }
 
     fn scroll_up(&mut self, _: &ScrollUp, window: &mut Window, cx: &mut Context<Self>) {
-        let scroll_top = self.list_state.logical_scroll_top();
-        if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) {
+        if let Some(bounds) = self
+            .scroll_handle
+            .bounds_for_item(self.scroll_handle.top_item())
+        {
             let item_height = bounds.size.height;
             // Scroll no more than the rough equivalent of a large headline
             let max_height = window.rem_size() * 2;
             let scroll_height = min(item_height, max_height);
-            self.list_state.scroll_by(-scroll_height);
+            self.scroll_by_amount(-scroll_height);
+        } else {
+            let scroll_height = self.line_scroll_amount(cx);
+            if !scroll_height.is_zero() {
+                self.scroll_by_amount(-scroll_height);
+            }
         }
         cx.notify();
     }
 
     fn scroll_down(&mut self, _: &ScrollDown, window: &mut Window, cx: &mut Context<Self>) {
-        let scroll_top = self.list_state.logical_scroll_top();
-        if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) {
+        if let Some(bounds) = self
+            .scroll_handle
+            .bounds_for_item(self.scroll_handle.top_item())
+        {
             let item_height = bounds.size.height;
             // Scroll no more than the rough equivalent of a large headline
             let max_height = window.rem_size() * 2;
             let scroll_height = min(item_height, max_height);
-            self.list_state.scroll_by(scroll_height);
+            self.scroll_by_amount(scroll_height);
+        } else {
+            let scroll_height = self.line_scroll_amount(cx);
+            if !scroll_height.is_zero() {
+                self.scroll_by_amount(scroll_height);
+            }
         }
         cx.notify();
     }
@@ -492,9 +529,11 @@ impl MarkdownPreviewView {
         _window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let scroll_top = self.list_state.logical_scroll_top();
-        if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) {
-            self.list_state.scroll_by(-bounds.size.height);
+        if let Some(bounds) = self
+            .scroll_handle
+            .bounds_for_item(self.scroll_handle.top_item())
+        {
+            self.scroll_by_amount(-bounds.size.height);
         }
         cx.notify();
     }
@@ -505,18 +544,17 @@ impl MarkdownPreviewView {
         _window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let scroll_top = self.list_state.logical_scroll_top();
-        if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) {
-            self.list_state.scroll_by(bounds.size.height);
+        if let Some(bounds) = self
+            .scroll_handle
+            .bounds_for_item(self.scroll_handle.top_item())
+        {
+            self.scroll_by_amount(bounds.size.height);
         }
         cx.notify();
     }
 
     fn scroll_to_top(&mut self, _: &ScrollToTop, _window: &mut Window, cx: &mut Context<Self>) {
-        self.list_state.scroll_to(ListOffset {
-            item_ix: 0,
-            offset_in_item: px(0.),
-        });
+        self.scroll_handle.scroll_to_item(0);
         cx.notify();
     }
 
@@ -526,19 +564,157 @@ impl MarkdownPreviewView {
         _window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let count = self.list_state.item_count();
-        if count > 0 {
-            self.list_state.scroll_to(ListOffset {
-                item_ix: count - 1,
-                offset_in_item: px(0.),
-            });
-        }
+        self.scroll_handle.scroll_to_bottom();
         cx.notify();
     }
+
+    fn render_markdown_element(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> MarkdownElement {
+        let workspace = self.workspace.clone();
+        let base_directory = self.base_directory.clone();
+        let active_editor = self
+            .active_editor
+            .as_ref()
+            .map(|state| state.editor.clone());
+
+        let mut markdown_element = MarkdownElement::new(
+            self.markdown.clone(),
+            MarkdownStyle::themed(MarkdownFont::Editor, window, cx),
+        )
+        .code_block_renderer(CodeBlockRenderer::Default {
+            copy_button: false,
+            copy_button_on_hover: true,
+            border: false,
+        })
+        .scroll_handle(self.scroll_handle.clone())
+        .show_root_block_markers()
+        .image_resolver({
+            let base_directory = self.base_directory.clone();
+            move |dest_url| resolve_preview_image(dest_url, base_directory.as_deref())
+        })
+        .on_url_click(move |url, window, cx| {
+            open_preview_url(url, base_directory.clone(), &workspace, window, cx);
+        });
+
+        if let Some(active_editor) = active_editor {
+            let editor_for_checkbox = active_editor.clone();
+            let view_handle = cx.entity().downgrade();
+            markdown_element = markdown_element
+                .on_source_click(move |source_index, click_count, window, cx| {
+                    if click_count == 2 {
+                        Self::move_cursor_to_source_index(&active_editor, source_index, window, cx);
+                        true
+                    } else {
+                        false
+                    }
+                })
+                .on_checkbox_toggle(move |source_range, new_checked, window, cx| {
+                    let task_marker = if new_checked { "[x]" } else { "[ ]" };
+                    editor_for_checkbox.update(cx, |editor, cx| {
+                        editor.edit(
+                            [(
+                                MultiBufferOffset(source_range.start)
+                                    ..MultiBufferOffset(source_range.end),
+                                task_marker,
+                            )],
+                            cx,
+                        );
+                    });
+                    if let Some(view) = view_handle.upgrade() {
+                        cx.update_entity(&view, |this, cx| {
+                            this.update_markdown_from_active_editor(false, false, window, cx);
+                        });
+                    }
+                });
+        }
+
+        markdown_element
+    }
+}
+
+fn open_preview_url(
+    url: SharedString,
+    base_directory: Option<PathBuf>,
+    workspace: &WeakEntity<Workspace>,
+    window: &mut Window,
+    cx: &mut App,
+) {
+    if let Some(path) = resolve_preview_path(url.as_ref(), base_directory.as_deref())
+        && let Some(workspace) = workspace.upgrade()
+    {
+        let _ = workspace.update(cx, |workspace, cx| {
+            workspace
+                .open_abs_path(
+                    normalize_path(path.as_path()),
+                    OpenOptions {
+                        visible: Some(OpenVisible::None),
+                        ..Default::default()
+                    },
+                    window,
+                    cx,
+                )
+                .detach();
+        });
+        return;
+    }
+
+    cx.open_url(url.as_ref());
+}
+
+fn resolve_preview_path(url: &str, base_directory: Option<&Path>) -> Option<PathBuf> {
+    if url.starts_with("http://") || url.starts_with("https://") {
+        return None;
+    }
+
+    let decoded_url = urlencoding::decode(url)
+        .map(|decoded| decoded.into_owned())
+        .unwrap_or_else(|_| url.to_string());
+    let candidate = PathBuf::from(&decoded_url);
+
+    if candidate.is_absolute() && candidate.exists() {
+        return Some(candidate);
+    }
+
+    let base_directory = base_directory?;
+    let resolved = base_directory.join(decoded_url);
+    if resolved.exists() {
+        Some(resolved)
+    } else {
+        None
+    }
+}
+
+fn resolve_preview_image(dest_url: &str, base_directory: Option<&Path>) -> Option<ImageSource> {
+    if dest_url.starts_with("data:") {
+        return None;
+    }
+
+    if dest_url.starts_with("http://") || dest_url.starts_with("https://") {
+        return Some(ImageSource::Resource(Resource::Uri(SharedUri::from(
+            dest_url.to_string(),
+        ))));
+    }
+
+    let decoded = urlencoding::decode(dest_url)
+        .map(|decoded| decoded.into_owned())
+        .unwrap_or_else(|_| dest_url.to_string());
+
+    let path = if Path::new(&decoded).is_absolute() {
+        PathBuf::from(decoded)
+    } else {
+        base_directory?.join(decoded)
+    };
+
+    Some(ImageSource::Resource(Resource::Path(Arc::from(
+        path.as_path(),
+    ))))
 }
 
 impl Focusable for MarkdownPreviewView {
-    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
+    fn focus_handle(&self, _: &App) -> FocusHandle {
         self.focus_handle.clone()
     }
 }
@@ -572,10 +748,7 @@ impl Item for MarkdownPreviewView {
 
 impl Render for MarkdownPreviewView {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let buffer_size = ThemeSettings::get_global(cx).buffer_font_size(cx);
-        let buffer_line_height = ThemeSettings::get_global(cx).buffer_line_height;
-
-        v_flex()
+        div()
             .image_cache(self.image_cache.clone())
             .id("MarkdownPreview")
             .key_context("MarkdownPreview")
@@ -590,113 +763,65 @@ impl Render for MarkdownPreviewView {
             .on_action(cx.listener(MarkdownPreviewView::scroll_to_bottom))
             .size_full()
             .bg(cx.theme().colors().editor_background)
-            .p_4()
-            .text_size(buffer_size)
-            .line_height(relative(buffer_line_height.value()))
-            .child(div().flex_grow().map(|this| {
-                this.child(
-                    list(
-                        self.list_state.clone(),
-                        cx.processor(|this, ix, window, cx| {
-                            let Some(contents) = &this.contents else {
-                                return div().into_any();
-                            };
-
-                            let mut render_cx = RenderContext::new(
-                                Some(this.workspace.clone()),
-                                &this.mermaid_state,
-                                window,
-                                cx,
-                            )
-                            .with_checkbox_clicked_callback(cx.listener(
-                                move |this, e: &CheckboxClickedEvent, window, cx| {
-                                    if let Some(editor) =
-                                        this.active_editor.as_ref().map(|s| s.editor.clone())
-                                    {
-                                        editor.update(cx, |editor, cx| {
-                                            let task_marker =
-                                                if e.checked() { "[x]" } else { "[ ]" };
-
-                                            editor.edit(
-                                                [(
-                                                    MultiBufferOffset(e.source_range().start)
-                                                        ..MultiBufferOffset(e.source_range().end),
-                                                    task_marker,
-                                                )],
-                                                cx,
-                                            );
-                                        });
-                                        this.parse_markdown_from_active_editor(false, window, cx);
-                                        cx.notify();
-                                    }
-                                },
-                            ));
-
-                            let block = contents.children.get(ix).unwrap();
-                            let rendered_block = render_markdown_block(block, &mut render_cx);
-
-                            let should_apply_padding = Self::should_apply_padding_between(
-                                block,
-                                contents.children.get(ix + 1),
-                            );
-
-                            let selected_block = this.selected_block;
-                            let scaled_rems = render_cx.scaled_rems(1.0);
-                            div()
-                                .id(ix)
-                                .when(should_apply_padding, |this| {
-                                    this.pb(render_cx.scaled_rems(0.75))
-                                })
-                                .group("markdown-block")
-                                .on_click(cx.listener(
-                                    move |this, event: &ClickEvent, window, cx| {
-                                        if event.click_count() == 2
-                                            && let Some(source_range) = this
-                                                .contents
-                                                .as_ref()
-                                                .and_then(|c| c.children.get(ix))
-                                                .and_then(|block: &ParsedMarkdownElement| {
-                                                    block.source_range()
-                                                })
-                                        {
-                                            this.move_cursor_to_block(
-                                                window,
-                                                cx,
-                                                MultiBufferOffset(source_range.start)
-                                                    ..MultiBufferOffset(source_range.start),
-                                            );
-                                        }
-                                    },
-                                ))
-                                .map(move |container| {
-                                    let indicator = div()
-                                        .h_full()
-                                        .w(px(4.0))
-                                        .when(ix == selected_block, |this| {
-                                            this.bg(cx.theme().colors().border)
-                                        })
-                                        .group_hover("markdown-block", |s| {
-                                            if ix == selected_block {
-                                                s
-                                            } else {
-                                                s.bg(cx.theme().colors().border_variant)
-                                            }
-                                        })
-                                        .rounded_xs();
-
-                                    container.child(
-                                        div()
-                                            .relative()
-                                            .child(div().pl(scaled_rems).child(rendered_block))
-                                            .child(indicator.absolute().left_0().top_0()),
-                                    )
-                                })
-                                .into_any()
-                        }),
-                    )
-                    .size_full(),
-                )
-            }))
-            .vertical_scrollbar_for(&self.list_state, window, cx)
+            .child(
+                div()
+                    .id("markdown-preview-scroll-container")
+                    .size_full()
+                    .overflow_y_scroll()
+                    .track_scroll(&self.scroll_handle)
+                    .p_4()
+                    .child(self.render_markdown_element(window, cx)),
+            )
+            .vertical_scrollbar_for(&self.scroll_handle, window, cx)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use anyhow::Result;
+    use std::fs;
+    use tempfile::TempDir;
+
+    use super::resolve_preview_path;
+
+    #[test]
+    fn resolves_relative_preview_paths() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let base_directory = temp_dir.path();
+        let file = base_directory.join("notes.md");
+        fs::write(&file, "# Notes")?;
+
+        assert_eq!(
+            resolve_preview_path("notes.md", Some(base_directory)),
+            Some(file)
+        );
+        assert_eq!(
+            resolve_preview_path("nonexistent.md", Some(base_directory)),
+            None
+        );
+        assert_eq!(resolve_preview_path("notes.md", None), None);
+
+        Ok(())
+    }
+
+    #[test]
+    fn resolves_urlencoded_preview_paths() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let base_directory = temp_dir.path();
+        let file = base_directory.join("release notes.md");
+        fs::write(&file, "# Release Notes")?;
+
+        assert_eq!(
+            resolve_preview_path("release%20notes.md", Some(base_directory)),
+            Some(file)
+        );
+
+        Ok(())
+    }
+
+    #[test]
+    fn does_not_treat_web_links_as_preview_paths() {
+        assert_eq!(resolve_preview_path("https://zed.dev", None), None);
+        assert_eq!(resolve_preview_path("http://example.com", None), None);
     }
 }

crates/markdown_preview/src/markdown_renderer.rs πŸ”—

@@ -1,1515 +0,0 @@
-use crate::{
-    markdown_elements::{
-        HeadingLevel, Image, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown,
-        ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, ParsedMarkdownElement,
-        ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType,
-        ParsedMarkdownMermaidDiagram, ParsedMarkdownMermaidDiagramContents, ParsedMarkdownTable,
-        ParsedMarkdownTableAlignment, ParsedMarkdownTableRow,
-    },
-    markdown_preview_view::MarkdownPreviewView,
-};
-use collections::HashMap;
-use gpui::{
-    AbsoluteLength, Animation, AnimationExt, AnyElement, App, AppContext as _, Context, Div,
-    Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement,
-    Keystroke, Modifiers, ParentElement, Render, RenderImage, Resource, SharedString, Styled,
-    StyledText, Task, TextStyle, WeakEntity, Window, div, img, pulsating_between, rems,
-};
-use settings::Settings;
-use std::{
-    ops::{Mul, Range},
-    sync::{Arc, OnceLock},
-    time::Duration,
-    vec,
-};
-use theme::{ActiveTheme, SyntaxTheme, ThemeSettings};
-use ui::{CopyButton, LinkPreview, ToggleState, prelude::*, tooltip_container};
-use util::normalize_path;
-use workspace::{OpenOptions, OpenVisible, Workspace};
-
-pub struct CheckboxClickedEvent {
-    pub checked: bool,
-    pub source_range: Range<usize>,
-}
-
-impl CheckboxClickedEvent {
-    pub fn source_range(&self) -> Range<usize> {
-        self.source_range.clone()
-    }
-
-    pub fn checked(&self) -> bool {
-        self.checked
-    }
-}
-
-type CheckboxClickedCallback = Arc<Box<dyn Fn(&CheckboxClickedEvent, &mut Window, &mut App)>>;
-
-type MermaidDiagramCache = HashMap<ParsedMarkdownMermaidDiagramContents, CachedMermaidDiagram>;
-
-#[derive(Default)]
-pub(crate) struct MermaidState {
-    cache: MermaidDiagramCache,
-    order: Vec<ParsedMarkdownMermaidDiagramContents>,
-}
-
-impl MermaidState {
-    fn get_fallback_image(
-        idx: usize,
-        old_order: &[ParsedMarkdownMermaidDiagramContents],
-        new_order_len: usize,
-        cache: &MermaidDiagramCache,
-    ) -> Option<Arc<RenderImage>> {
-        // When the diagram count changes e.g. addition or removal, positional matching
-        // is unreliable since a new diagram at index i likely doesn't correspond to the
-        // old diagram at index i. We only allow fallbacks when counts match, which covers
-        // the common case of editing a diagram in-place.
-        //
-        // Swapping two diagrams would briefly show the stale fallback, but that's an edge
-        // case we don't handle.
-        if old_order.len() != new_order_len {
-            return None;
-        }
-        old_order.get(idx).and_then(|old_content| {
-            cache.get(old_content).and_then(|old_cached| {
-                old_cached
-                    .render_image
-                    .get()
-                    .and_then(|result| result.as_ref().ok().cloned())
-                    // Chain fallbacks for rapid edits.
-                    .or_else(|| old_cached.fallback_image.clone())
-            })
-        })
-    }
-
-    pub(crate) fn update(
-        &mut self,
-        parsed: &ParsedMarkdown,
-        cx: &mut Context<MarkdownPreviewView>,
-    ) {
-        use crate::markdown_elements::ParsedMarkdownElement;
-        use std::collections::HashSet;
-
-        let mut new_order = Vec::new();
-        for element in parsed.children.iter() {
-            if let ParsedMarkdownElement::MermaidDiagram(mermaid_diagram) = element {
-                new_order.push(mermaid_diagram.contents.clone());
-            }
-        }
-
-        for (idx, new_content) in new_order.iter().enumerate() {
-            if !self.cache.contains_key(new_content) {
-                let fallback =
-                    Self::get_fallback_image(idx, &self.order, new_order.len(), &self.cache);
-                self.cache.insert(
-                    new_content.clone(),
-                    CachedMermaidDiagram::new(new_content.clone(), fallback, cx),
-                );
-            }
-        }
-
-        let new_order_set: HashSet<_> = new_order.iter().cloned().collect();
-        self.cache
-            .retain(|content, _| new_order_set.contains(content));
-        self.order = new_order;
-    }
-}
-
-pub(crate) struct CachedMermaidDiagram {
-    pub(crate) render_image: Arc<OnceLock<anyhow::Result<Arc<RenderImage>>>>,
-    pub(crate) fallback_image: Option<Arc<RenderImage>>,
-    _task: Task<()>,
-}
-
-impl CachedMermaidDiagram {
-    pub(crate) fn new(
-        contents: ParsedMarkdownMermaidDiagramContents,
-        fallback_image: Option<Arc<RenderImage>>,
-        cx: &mut Context<MarkdownPreviewView>,
-    ) -> Self {
-        let result = Arc::new(OnceLock::<anyhow::Result<Arc<RenderImage>>>::new());
-        let result_clone = result.clone();
-        let svg_renderer = cx.svg_renderer();
-
-        let _task = cx.spawn(async move |this, cx| {
-            let value = cx
-                .background_spawn(async move {
-                    let svg_string = mermaid_rs_renderer::render(&contents.contents)?;
-                    let scale = contents.scale as f32 / 100.0;
-                    svg_renderer
-                        .render_single_frame(svg_string.as_bytes(), scale, true)
-                        .map_err(|e| anyhow::anyhow!("{}", e))
-                })
-                .await;
-            let _ = result_clone.set(value);
-            this.update(cx, |_, cx| {
-                cx.notify();
-            })
-            .ok();
-        });
-
-        Self {
-            render_image: result,
-            fallback_image,
-            _task,
-        }
-    }
-
-    #[cfg(test)]
-    fn new_for_test(
-        render_image: Option<Arc<RenderImage>>,
-        fallback_image: Option<Arc<RenderImage>>,
-    ) -> Self {
-        let result = Arc::new(OnceLock::new());
-        if let Some(img) = render_image {
-            let _ = result.set(Ok(img));
-        }
-        Self {
-            render_image: result,
-            fallback_image,
-            _task: Task::ready(()),
-        }
-    }
-}
-#[derive(Clone)]
-pub struct RenderContext<'a> {
-    workspace: Option<WeakEntity<Workspace>>,
-    next_id: usize,
-    buffer_font_family: SharedString,
-    buffer_text_style: TextStyle,
-    text_style: TextStyle,
-    border_color: Hsla,
-    title_bar_background_color: Hsla,
-    panel_background_color: Hsla,
-    text_color: Hsla,
-    link_color: Hsla,
-    window_rem_size: Pixels,
-    text_muted_color: Hsla,
-    code_block_background_color: Hsla,
-    code_span_background_color: Hsla,
-    syntax_theme: Arc<SyntaxTheme>,
-    indent: usize,
-    checkbox_clicked_callback: Option<CheckboxClickedCallback>,
-    is_last_child: bool,
-    mermaid_state: &'a MermaidState,
-}
-
-impl<'a> RenderContext<'a> {
-    pub(crate) fn new(
-        workspace: Option<WeakEntity<Workspace>>,
-        mermaid_state: &'a MermaidState,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> Self {
-        let theme = cx.theme().clone();
-
-        let settings = ThemeSettings::get_global(cx);
-        let buffer_font_family = settings.buffer_font.family.clone();
-        let buffer_font_features = settings.buffer_font.features.clone();
-        let mut buffer_text_style = window.text_style();
-        buffer_text_style.font_family = buffer_font_family.clone();
-        buffer_text_style.font_features = buffer_font_features;
-        buffer_text_style.font_size = AbsoluteLength::from(settings.buffer_font_size(cx));
-
-        RenderContext {
-            workspace,
-            next_id: 0,
-            indent: 0,
-            buffer_font_family,
-            buffer_text_style,
-            text_style: window.text_style(),
-            syntax_theme: theme.syntax().clone(),
-            border_color: theme.colors().border,
-            title_bar_background_color: theme.colors().title_bar_background,
-            panel_background_color: theme.colors().panel_background,
-            text_color: theme.colors().text,
-            link_color: theme.colors().text_accent,
-            window_rem_size: window.rem_size(),
-            text_muted_color: theme.colors().text_muted,
-            code_block_background_color: theme.colors().surface_background,
-            code_span_background_color: theme.colors().editor_document_highlight_read_background,
-            checkbox_clicked_callback: None,
-            is_last_child: false,
-            mermaid_state,
-        }
-    }
-
-    pub fn with_checkbox_clicked_callback(
-        mut self,
-        callback: impl Fn(&CheckboxClickedEvent, &mut Window, &mut App) + 'static,
-    ) -> Self {
-        self.checkbox_clicked_callback = Some(Arc::new(Box::new(callback)));
-        self
-    }
-
-    fn next_id(&mut self, span: &Range<usize>) -> ElementId {
-        let id = format!("markdown-{}-{}-{}", self.next_id, span.start, span.end);
-        self.next_id += 1;
-        ElementId::from(SharedString::from(id))
-    }
-
-    /// HACK: used to have rems relative to buffer font size, so that things scale appropriately as
-    /// buffer font size changes. The callees of this function should be reimplemented to use real
-    /// relative sizing once that is implemented in GPUI
-    pub fn scaled_rems(&self, rems: f32) -> Rems {
-        self.buffer_text_style
-            .font_size
-            .to_rems(self.window_rem_size)
-            .mul(rems)
-    }
-
-    /// This ensures that children inside of block quotes
-    /// have padding between them.
-    ///
-    /// For example, for this markdown:
-    ///
-    /// ```markdown
-    /// > This is a block quote.
-    /// >
-    /// > And this is the next paragraph.
-    /// ```
-    ///
-    /// We give padding between "This is a block quote."
-    /// and "And this is the next paragraph."
-    fn with_common_p(&self, element: Div) -> Div {
-        if self.indent > 0 && !self.is_last_child {
-            element.pb(self.scaled_rems(0.75))
-        } else {
-            element
-        }
-    }
-
-    /// The is used to indicate that the current element is the last child or not of its parent.
-    ///
-    /// Then we can avoid adding padding to the bottom of the last child.
-    fn with_last_child<R>(&mut self, is_last: bool, render: R) -> AnyElement
-    where
-        R: FnOnce(&mut Self) -> AnyElement,
-    {
-        self.is_last_child = is_last;
-        let element = render(self);
-        self.is_last_child = false;
-        element
-    }
-}
-
-pub fn render_parsed_markdown(
-    parsed: &ParsedMarkdown,
-    workspace: Option<WeakEntity<Workspace>>,
-    window: &mut Window,
-    cx: &mut App,
-) -> Div {
-    let cache = Default::default();
-    let mut cx = RenderContext::new(workspace, &cache, window, cx);
-
-    v_flex().gap_3().children(
-        parsed
-            .children
-            .iter()
-            .map(|block| render_markdown_block(block, &mut cx)),
-    )
-}
-pub fn render_markdown_block(block: &ParsedMarkdownElement, cx: &mut RenderContext) -> AnyElement {
-    use ParsedMarkdownElement::*;
-    match block {
-        Paragraph(text) => render_markdown_paragraph(text, cx),
-        Heading(heading) => render_markdown_heading(heading, cx),
-        ListItem(list_item) => render_markdown_list_item(list_item, cx),
-        Table(table) => render_markdown_table(table, cx),
-        BlockQuote(block_quote) => render_markdown_block_quote(block_quote, cx),
-        CodeBlock(code_block) => render_markdown_code_block(code_block, cx),
-        MermaidDiagram(mermaid) => render_mermaid_diagram(mermaid, cx),
-        HorizontalRule(_) => render_markdown_rule(cx),
-        Image(image) => render_markdown_image(image, cx),
-    }
-}
-
-fn render_markdown_heading(parsed: &ParsedMarkdownHeading, cx: &mut RenderContext) -> AnyElement {
-    let size = match parsed.level {
-        HeadingLevel::H1 => 2.,
-        HeadingLevel::H2 => 1.5,
-        HeadingLevel::H3 => 1.25,
-        HeadingLevel::H4 => 1.,
-        HeadingLevel::H5 => 0.875,
-        HeadingLevel::H6 => 0.85,
-    };
-
-    let text_size = cx.scaled_rems(size);
-
-    // was `DefiniteLength::from(text_size.mul(1.25))`
-    // let line_height = DefiniteLength::from(text_size.mul(1.25));
-    let line_height = text_size * 1.25;
-
-    // was `rems(0.15)`
-    // let padding_top = cx.scaled_rems(0.15);
-    let padding_top = rems(0.15);
-
-    // was `.pb_1()` = `rems(0.25)`
-    // let padding_bottom = cx.scaled_rems(0.25);
-    let padding_bottom = rems(0.25);
-
-    let color = match parsed.level {
-        HeadingLevel::H6 => cx.text_muted_color,
-        _ => cx.text_color,
-    };
-    div()
-        .line_height(line_height)
-        .text_size(text_size)
-        .text_color(color)
-        .pt(padding_top)
-        .pb(padding_bottom)
-        .children(render_markdown_text(&parsed.contents, cx))
-        .whitespace_normal()
-        .into_any()
-}
-
-fn render_markdown_list_item(
-    parsed: &ParsedMarkdownListItem,
-    cx: &mut RenderContext,
-) -> AnyElement {
-    use ParsedMarkdownListItemType::*;
-    let depth = parsed.depth.saturating_sub(1) as usize;
-
-    let bullet = match &parsed.item_type {
-        Ordered(order) => list_item_prefix(*order as usize, true, depth).into_any_element(),
-        Unordered => list_item_prefix(1, false, depth).into_any_element(),
-        Task(checked, range) => div()
-            .id(cx.next_id(range))
-            .mt(cx.scaled_rems(3.0 / 16.0))
-            .child(
-                MarkdownCheckbox::new(
-                    "checkbox",
-                    if *checked {
-                        ToggleState::Selected
-                    } else {
-                        ToggleState::Unselected
-                    },
-                    cx.clone(),
-                )
-                .when_some(
-                    cx.checkbox_clicked_callback.clone(),
-                    |this, callback| {
-                        this.on_click({
-                            let range = range.clone();
-                            move |selection, window, cx| {
-                                let checked = match selection {
-                                    ToggleState::Selected => true,
-                                    ToggleState::Unselected => false,
-                                    _ => return,
-                                };
-
-                                if window.modifiers().secondary() {
-                                    callback(
-                                        &CheckboxClickedEvent {
-                                            checked,
-                                            source_range: range.clone(),
-                                        },
-                                        window,
-                                        cx,
-                                    );
-                                }
-                            }
-                        })
-                    },
-                ),
-            )
-            .hover(|s| s.cursor_pointer())
-            .tooltip(|_, cx| {
-                InteractiveMarkdownElementTooltip::new(None, "toggle checkbox", cx).into()
-            })
-            .into_any_element(),
-    };
-    let bullet = div().mr(cx.scaled_rems(0.5)).child(bullet);
-
-    let contents: Vec<AnyElement> = parsed
-        .content
-        .iter()
-        .map(|c| render_markdown_block(c, cx))
-        .collect();
-
-    let item = h_flex()
-        .when(!parsed.nested, |this| this.pl(cx.scaled_rems(depth as f32)))
-        .when(parsed.nested && depth > 0, |this| this.ml_neg_1p5())
-        .items_start()
-        .children(vec![
-            bullet,
-            v_flex()
-                .children(contents)
-                .when(!parsed.nested, |this| this.gap(cx.scaled_rems(1.0)))
-                .pr(cx.scaled_rems(1.0))
-                .w_full(),
-        ]);
-
-    cx.with_common_p(item).into_any()
-}
-
-/// # MarkdownCheckbox ///
-/// HACK: Copied from `ui/src/components/toggle.rs` to deal with scaling issues in markdown preview
-/// changes should be integrated into `Checkbox` in `toggle.rs` while making sure checkboxes elsewhere in the
-/// app are not visually affected
-#[derive(gpui::IntoElement)]
-struct MarkdownCheckbox {
-    id: ElementId,
-    toggle_state: ToggleState,
-    disabled: bool,
-    placeholder: bool,
-    on_click: Option<Box<dyn Fn(&ToggleState, &mut Window, &mut App) + 'static>>,
-    filled: bool,
-    style: ui::ToggleStyle,
-    tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> gpui::AnyView>>,
-    label: Option<SharedString>,
-    base_rem: Rems,
-}
-
-impl MarkdownCheckbox {
-    /// Creates a new [`Checkbox`].
-    fn new(id: impl Into<ElementId>, checked: ToggleState, render_cx: RenderContext) -> Self {
-        Self {
-            id: id.into(),
-            toggle_state: checked,
-            disabled: false,
-            on_click: None,
-            filled: false,
-            style: ui::ToggleStyle::default(),
-            tooltip: None,
-            label: None,
-            placeholder: false,
-            base_rem: render_cx.scaled_rems(1.0),
-        }
-    }
-
-    /// Binds a handler to the [`Checkbox`] that will be called when clicked.
-    fn on_click(mut self, handler: impl Fn(&ToggleState, &mut Window, &mut App) + 'static) -> Self {
-        self.on_click = Some(Box::new(handler));
-        self
-    }
-
-    fn bg_color(&self, cx: &App) -> Hsla {
-        let style = self.style.clone();
-        match (style, self.filled) {
-            (ui::ToggleStyle::Ghost, false) => cx.theme().colors().ghost_element_background,
-            (ui::ToggleStyle::Ghost, true) => cx.theme().colors().element_background,
-            (ui::ToggleStyle::ElevationBased(_), false) => gpui::transparent_black(),
-            (ui::ToggleStyle::ElevationBased(elevation), true) => elevation.darker_bg(cx),
-            (ui::ToggleStyle::Custom(_), false) => gpui::transparent_black(),
-            (ui::ToggleStyle::Custom(color), true) => color.opacity(0.2),
-        }
-    }
-
-    fn border_color(&self, cx: &App) -> Hsla {
-        if self.disabled {
-            return cx.theme().colors().border_variant;
-        }
-
-        match self.style.clone() {
-            ui::ToggleStyle::Ghost => cx.theme().colors().border,
-            ui::ToggleStyle::ElevationBased(_) => cx.theme().colors().border,
-            ui::ToggleStyle::Custom(color) => color.opacity(0.3),
-        }
-    }
-}
-
-impl gpui::RenderOnce for MarkdownCheckbox {
-    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
-        let group_id = format!("checkbox_group_{:?}", self.id);
-        let color = if self.disabled {
-            Color::Disabled
-        } else {
-            Color::Selected
-        };
-        let icon_size_small = IconSize::Custom(self.base_rem.mul(14. / 16.)); // was IconSize::Small
-        let icon = match self.toggle_state {
-            ToggleState::Selected => {
-                if self.placeholder {
-                    None
-                } else {
-                    Some(
-                        ui::Icon::new(IconName::Check)
-                            .size(icon_size_small)
-                            .color(color),
-                    )
-                }
-            }
-            ToggleState::Indeterminate => Some(
-                ui::Icon::new(IconName::Dash)
-                    .size(icon_size_small)
-                    .color(color),
-            ),
-            ToggleState::Unselected => None,
-        };
-
-        let bg_color = self.bg_color(cx);
-        let border_color = self.border_color(cx);
-        let hover_border_color = border_color.alpha(0.7);
-
-        let size = self.base_rem.mul(1.25); // was Self::container_size(); (20px)
-
-        let checkbox = h_flex()
-            .id(self.id.clone())
-            .justify_center()
-            .items_center()
-            .size(size)
-            .group(group_id.clone())
-            .child(
-                div()
-                    .flex()
-                    .flex_none()
-                    .justify_center()
-                    .items_center()
-                    .m(self.base_rem.mul(0.25)) // was .m_1
-                    .size(self.base_rem.mul(1.0)) // was .size_4
-                    .rounded(self.base_rem.mul(0.125)) // was .rounded_xs
-                    .border_1()
-                    .bg(bg_color)
-                    .border_color(border_color)
-                    .when(self.disabled, |this| this.cursor_not_allowed())
-                    .when(self.disabled, |this| {
-                        this.bg(cx.theme().colors().element_disabled.opacity(0.6))
-                    })
-                    .when(!self.disabled, |this| {
-                        this.group_hover(group_id.clone(), |el| el.border_color(hover_border_color))
-                    })
-                    .when(self.placeholder, |this| {
-                        this.child(
-                            div()
-                                .flex_none()
-                                .rounded_full()
-                                .bg(color.color(cx).alpha(0.5))
-                                .size(self.base_rem.mul(0.25)), // was .size_1
-                        )
-                    })
-                    .children(icon),
-            );
-
-        h_flex()
-            .id(self.id)
-            .gap(ui::DynamicSpacing::Base06.rems(cx))
-            .child(checkbox)
-            .when_some(
-                self.on_click.filter(|_| !self.disabled),
-                |this, on_click| {
-                    this.on_click(move |_, window, cx| {
-                        on_click(&self.toggle_state.inverse(), window, cx)
-                    })
-                },
-            )
-            // TODO: Allow label size to be different from default.
-            // TODO: Allow label color to be different from muted.
-            .when_some(self.label, |this, label| {
-                this.child(Label::new(label).color(Color::Muted))
-            })
-            .when_some(self.tooltip, |this, tooltip| {
-                this.tooltip(move |window, cx| tooltip(window, cx))
-            })
-    }
-}
-
-fn calculate_table_columns_count(rows: &Vec<ParsedMarkdownTableRow>) -> usize {
-    let mut actual_column_count = 0;
-    for row in rows {
-        actual_column_count = actual_column_count.max(
-            row.columns
-                .iter()
-                .map(|column| column.col_span)
-                .sum::<usize>(),
-        );
-    }
-    actual_column_count
-}
-
-fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -> AnyElement {
-    let actual_header_column_count = calculate_table_columns_count(&parsed.header);
-    let actual_body_column_count = calculate_table_columns_count(&parsed.body);
-    let max_column_count = std::cmp::max(actual_header_column_count, actual_body_column_count);
-
-    let total_rows = parsed.header.len() + parsed.body.len();
-
-    // Track which grid cells are occupied by spanning cells
-    let mut grid_occupied = vec![vec![false; max_column_count]; total_rows];
-
-    let mut cells = Vec::with_capacity(total_rows * max_column_count);
-
-    for (row_idx, row) in parsed.header.iter().chain(parsed.body.iter()).enumerate() {
-        let mut col_idx = 0;
-
-        for cell in row.columns.iter() {
-            // Skip columns occupied by row-spanning cells from previous rows
-            while col_idx < max_column_count && grid_occupied[row_idx][col_idx] {
-                col_idx += 1;
-            }
-
-            if col_idx >= max_column_count {
-                break;
-            }
-
-            let container = match cell.alignment {
-                ParsedMarkdownTableAlignment::Left | ParsedMarkdownTableAlignment::None => div(),
-                ParsedMarkdownTableAlignment::Center => v_flex().items_center(),
-                ParsedMarkdownTableAlignment::Right => v_flex().items_end(),
-            };
-
-            let cell_element = container
-                .col_span(cell.col_span.min(max_column_count - col_idx) as u16)
-                .row_span(cell.row_span.min(total_rows - row_idx) as u16)
-                .children(render_markdown_text(&cell.children, cx))
-                .px_2()
-                .py_1()
-                .when(col_idx > 0, |this| this.border_l_1())
-                .when(row_idx > 0, |this| this.border_t_1())
-                .border_color(cx.border_color)
-                .when(cell.is_header, |this| {
-                    this.bg(cx.title_bar_background_color)
-                })
-                .when(cell.row_span > 1, |this| this.justify_center())
-                .when(row_idx % 2 == 1, |this| this.bg(cx.panel_background_color));
-
-            cells.push(cell_element);
-
-            // Mark grid positions as occupied for row-spanning cells
-            for r in 0..cell.row_span {
-                for c in 0..cell.col_span {
-                    if row_idx + r < total_rows && col_idx + c < max_column_count {
-                        grid_occupied[row_idx + r][col_idx + c] = true;
-                    }
-                }
-            }
-
-            col_idx += cell.col_span;
-        }
-
-        // Fill remaining columns with empty cells if needed
-        while col_idx < max_column_count {
-            if grid_occupied[row_idx][col_idx] {
-                col_idx += 1;
-                continue;
-            }
-
-            let empty_cell = div()
-                .when(col_idx > 0, |this| this.border_l_1())
-                .when(row_idx > 0, |this| this.border_t_1())
-                .border_color(cx.border_color)
-                .when(row_idx % 2 == 1, |this| this.bg(cx.panel_background_color));
-
-            cells.push(empty_cell);
-            col_idx += 1;
-        }
-    }
-
-    cx.with_common_p(v_flex().items_start())
-        .when_some(parsed.caption.as_ref(), |this, caption| {
-            this.children(render_markdown_text(caption, cx))
-        })
-        .child(
-            div()
-                .rounded_sm()
-                .overflow_hidden()
-                .border_1()
-                .border_color(cx.border_color)
-                .min_w_0()
-                .grid()
-                .grid_cols_max_content(max_column_count as u16)
-                .children(cells),
-        )
-        .into_any()
-}
-
-fn render_markdown_block_quote(
-    parsed: &ParsedMarkdownBlockQuote,
-    cx: &mut RenderContext,
-) -> AnyElement {
-    cx.indent += 1;
-
-    let children: Vec<AnyElement> = parsed
-        .children
-        .iter()
-        .enumerate()
-        .map(|(ix, child)| {
-            cx.with_last_child(ix + 1 == parsed.children.len(), |cx| {
-                render_markdown_block(child, cx)
-            })
-        })
-        .collect();
-
-    cx.indent -= 1;
-
-    cx.with_common_p(div())
-        .child(
-            div()
-                .border_l_4()
-                .border_color(cx.border_color)
-                .pl_3()
-                .children(children),
-        )
-        .into_any()
-}
-
-fn render_markdown_code_block(
-    parsed: &ParsedMarkdownCodeBlock,
-    cx: &mut RenderContext,
-) -> AnyElement {
-    let body = if let Some(highlights) = parsed.highlights.as_ref() {
-        StyledText::new(parsed.contents.clone()).with_default_highlights(
-            &cx.buffer_text_style,
-            highlights.iter().filter_map(|(range, highlight_id)| {
-                highlight_id
-                    .style(cx.syntax_theme.as_ref())
-                    .map(|style| (range.clone(), style))
-            }),
-        )
-    } else {
-        StyledText::new(parsed.contents.clone())
-    };
-
-    let copy_block_button = CopyButton::new("copy-codeblock", parsed.contents.clone())
-        .tooltip_label("Copy Codeblock")
-        .visible_on_hover("markdown-block");
-
-    let font = gpui::Font {
-        family: cx.buffer_font_family.clone(),
-        features: cx.buffer_text_style.font_features.clone(),
-        ..Default::default()
-    };
-
-    cx.with_common_p(div())
-        .font(font)
-        .px_3()
-        .py_3()
-        .bg(cx.code_block_background_color)
-        .rounded_sm()
-        .child(body)
-        .child(
-            div()
-                .h_flex()
-                .absolute()
-                .right_1()
-                .top_1()
-                .child(copy_block_button),
-        )
-        .into_any()
-}
-
-fn render_mermaid_diagram(
-    parsed: &ParsedMarkdownMermaidDiagram,
-    cx: &mut RenderContext,
-) -> AnyElement {
-    let cached = cx.mermaid_state.cache.get(&parsed.contents);
-
-    if let Some(result) = cached.and_then(|c| c.render_image.get()) {
-        match result {
-            Ok(render_image) => cx
-                .with_common_p(div())
-                .px_3()
-                .py_3()
-                .bg(cx.code_block_background_color)
-                .rounded_sm()
-                .child(
-                    div().w_full().child(
-                        img(ImageSource::Render(render_image.clone()))
-                            .max_w_full()
-                            .with_fallback(|| {
-                                div()
-                                    .child(Label::new("Failed to load mermaid diagram"))
-                                    .into_any_element()
-                            }),
-                    ),
-                )
-                .into_any(),
-            Err(_) => cx
-                .with_common_p(div())
-                .px_3()
-                .py_3()
-                .bg(cx.code_block_background_color)
-                .rounded_sm()
-                .child(StyledText::new(parsed.contents.contents.clone()))
-                .into_any(),
-        }
-    } else if let Some(fallback) = cached.and_then(|c| c.fallback_image.as_ref()) {
-        cx.with_common_p(div())
-            .px_3()
-            .py_3()
-            .bg(cx.code_block_background_color)
-            .rounded_sm()
-            .child(
-                div()
-                    .w_full()
-                    .child(
-                        img(ImageSource::Render(fallback.clone()))
-                            .max_w_full()
-                            .with_fallback(|| {
-                                div()
-                                    .child(Label::new("Failed to load mermaid diagram"))
-                                    .into_any_element()
-                            }),
-                    )
-                    .with_animation(
-                        "mermaid-fallback-pulse",
-                        Animation::new(Duration::from_secs(2))
-                            .repeat()
-                            .with_easing(pulsating_between(0.6, 1.0)),
-                        |el, delta| el.opacity(delta),
-                    ),
-            )
-            .into_any()
-    } else {
-        cx.with_common_p(div())
-            .px_3()
-            .py_3()
-            .bg(cx.code_block_background_color)
-            .rounded_sm()
-            .child(
-                Label::new("Rendering mermaid diagram...")
-                    .color(Color::Muted)
-                    .with_animation(
-                        "mermaid-loading-pulse",
-                        Animation::new(Duration::from_secs(2))
-                            .repeat()
-                            .with_easing(pulsating_between(0.4, 0.8)),
-                        |label, delta| label.alpha(delta),
-                    ),
-            )
-            .into_any()
-    }
-}
-
-fn render_markdown_paragraph(parsed: &MarkdownParagraph, cx: &mut RenderContext) -> AnyElement {
-    cx.with_common_p(div())
-        .children(render_markdown_text(parsed, cx))
-        .flex()
-        .flex_col()
-        .into_any_element()
-}
-
-fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) -> Vec<AnyElement> {
-    let mut any_element = Vec::with_capacity(parsed_new.len());
-    // these values are cloned in-order satisfy borrow checker
-    let syntax_theme = cx.syntax_theme.clone();
-    let workspace_clone = cx.workspace.clone();
-    let code_span_bg_color = cx.code_span_background_color;
-    let text_style = cx.text_style.clone();
-    let link_color = cx.link_color;
-
-    for parsed_region in parsed_new {
-        match parsed_region {
-            MarkdownParagraphChunk::Text(parsed) => {
-                let trimmed = parsed.contents.trim();
-                if trimmed == "[x]" || trimmed == "[X]" || trimmed == "[ ]" {
-                    let checked = trimmed != "[ ]";
-                    let element = div()
-                        .child(MarkdownCheckbox::new(
-                            cx.next_id(&parsed.source_range),
-                            if checked {
-                                ToggleState::Selected
-                            } else {
-                                ToggleState::Unselected
-                            },
-                            cx.clone(),
-                        ))
-                        .into_any();
-                    any_element.push(element);
-                    continue;
-                }
-
-                let element_id = cx.next_id(&parsed.source_range);
-
-                let highlights = gpui::combine_highlights(
-                    parsed.highlights.iter().filter_map(|(range, highlight)| {
-                        highlight
-                            .to_highlight_style(&syntax_theme)
-                            .map(|style| (range.clone(), style))
-                    }),
-                    parsed.regions.iter().filter_map(|(range, region)| {
-                        if region.code {
-                            Some((
-                                range.clone(),
-                                HighlightStyle {
-                                    background_color: Some(code_span_bg_color),
-                                    ..Default::default()
-                                },
-                            ))
-                        } else if region.link.is_some() {
-                            Some((
-                                range.clone(),
-                                HighlightStyle {
-                                    color: Some(link_color),
-                                    ..Default::default()
-                                },
-                            ))
-                        } else {
-                            None
-                        }
-                    }),
-                );
-                let mut links = Vec::new();
-                let mut link_ranges = Vec::new();
-                for (range, region) in parsed.regions.iter() {
-                    if let Some(link) = region.link.clone() {
-                        links.push(link);
-                        link_ranges.push(range.clone());
-                    }
-                }
-                let workspace = workspace_clone.clone();
-                let element = div()
-                    .child(
-                        InteractiveText::new(
-                            element_id,
-                            StyledText::new(parsed.contents.clone())
-                                .with_default_highlights(&text_style, highlights),
-                        )
-                        .tooltip({
-                            let links = links.clone();
-                            let link_ranges = link_ranges.clone();
-                            move |idx, _, cx| {
-                                for (ix, range) in link_ranges.iter().enumerate() {
-                                    if range.contains(&idx) {
-                                        return Some(LinkPreview::new(&links[ix].to_string(), cx));
-                                    }
-                                }
-                                None
-                            }
-                        })
-                        .on_click(
-                            link_ranges,
-                            move |clicked_range_ix, window, cx| match &links[clicked_range_ix] {
-                                Link::Web { url } => cx.open_url(url),
-                                Link::Path { path, .. } => {
-                                    if let Some(workspace) = &workspace {
-                                        _ = workspace.update(cx, |workspace, cx| {
-                                            workspace
-                                                .open_abs_path(
-                                                    normalize_path(path.clone().as_path()),
-                                                    OpenOptions {
-                                                        visible: Some(OpenVisible::None),
-                                                        ..Default::default()
-                                                    },
-                                                    window,
-                                                    cx,
-                                                )
-                                                .detach();
-                                        });
-                                    }
-                                }
-                            },
-                        ),
-                    )
-                    .into_any();
-                any_element.push(element);
-            }
-
-            MarkdownParagraphChunk::Image(image) => {
-                any_element.push(render_markdown_image(image, cx));
-            }
-        }
-    }
-
-    any_element
-}
-
-fn render_markdown_rule(cx: &mut RenderContext) -> AnyElement {
-    let rule = div().w_full().h(cx.scaled_rems(0.125)).bg(cx.border_color);
-    div().py(cx.scaled_rems(0.5)).child(rule).into_any()
-}
-
-fn render_markdown_image(image: &Image, cx: &mut RenderContext) -> AnyElement {
-    let image_resource = match image.link.clone() {
-        Link::Web { url } => Resource::Uri(url.into()),
-        Link::Path { path, .. } => Resource::Path(Arc::from(path)),
-    };
-
-    let element_id = cx.next_id(&image.source_range);
-    let workspace = cx.workspace.clone();
-
-    div()
-        .id(element_id)
-        .cursor_pointer()
-        .child(
-            img(ImageSource::Resource(image_resource))
-                .max_w_full()
-                .with_fallback({
-                    let alt_text = image.alt_text.clone();
-                    move || div().children(alt_text.clone()).into_any_element()
-                })
-                .when_some(image.height, |this, height| this.h(height))
-                .when_some(image.width, |this, width| this.w(width)),
-        )
-        .tooltip({
-            let link = image.link.clone();
-            let alt_text = image.alt_text.clone();
-            move |_, cx| {
-                InteractiveMarkdownElementTooltip::new(
-                    Some(alt_text.clone().unwrap_or(link.to_string().into())),
-                    "open image",
-                    cx,
-                )
-                .into()
-            }
-        })
-        .on_click({
-            let link = image.link.clone();
-            move |_, window, cx| {
-                if window.modifiers().secondary() {
-                    match &link {
-                        Link::Web { url } => cx.open_url(url),
-                        Link::Path { path, .. } => {
-                            if let Some(workspace) = &workspace {
-                                _ = workspace.update(cx, |workspace, cx| {
-                                    workspace
-                                        .open_abs_path(
-                                            path.clone(),
-                                            OpenOptions {
-                                                visible: Some(OpenVisible::None),
-                                                ..Default::default()
-                                            },
-                                            window,
-                                            cx,
-                                        )
-                                        .detach();
-                                });
-                            }
-                        }
-                    }
-                }
-            }
-        })
-        .into_any()
-}
-
-struct InteractiveMarkdownElementTooltip {
-    tooltip_text: Option<SharedString>,
-    action_text: SharedString,
-}
-
-impl InteractiveMarkdownElementTooltip {
-    pub fn new(
-        tooltip_text: Option<SharedString>,
-        action_text: impl Into<SharedString>,
-        cx: &mut App,
-    ) -> Entity<Self> {
-        let tooltip_text = tooltip_text.map(|t| util::truncate_and_trailoff(&t, 50).into());
-
-        cx.new(|_cx| Self {
-            tooltip_text,
-            action_text: action_text.into(),
-        })
-    }
-}
-
-impl Render for InteractiveMarkdownElementTooltip {
-    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        tooltip_container(cx, |el, _| {
-            let secondary_modifier = Keystroke {
-                modifiers: Modifiers::secondary_key(),
-                ..Default::default()
-            };
-
-            el.child(
-                v_flex()
-                    .gap_1()
-                    .when_some(self.tooltip_text.clone(), |this, text| {
-                        this.child(Label::new(text).size(LabelSize::Small))
-                    })
-                    .child(
-                        Label::new(format!(
-                            "{}-click to {}",
-                            secondary_modifier, self.action_text
-                        ))
-                        .size(LabelSize::Small)
-                        .color(Color::Muted),
-                    ),
-            )
-        })
-    }
-}
-
-/// Returns the prefix for a list item.
-fn list_item_prefix(order: usize, ordered: bool, depth: usize) -> String {
-    let ix = order.saturating_sub(1);
-    const NUMBERED_PREFIXES_1: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
-    const NUMBERED_PREFIXES_2: &str = "abcdefghijklmnopqrstuvwxyz";
-    const BULLETS: [&str; 5] = ["β€’", "β—¦", "β–ͺ", "β€£", "⁃"];
-
-    if ordered {
-        match depth {
-            0 => format!("{}. ", order),
-            1 => format!(
-                "{}. ",
-                NUMBERED_PREFIXES_1
-                    .chars()
-                    .nth(ix % NUMBERED_PREFIXES_1.len())
-                    .unwrap()
-            ),
-            _ => format!(
-                "{}. ",
-                NUMBERED_PREFIXES_2
-                    .chars()
-                    .nth(ix % NUMBERED_PREFIXES_2.len())
-                    .unwrap()
-            ),
-        }
-    } else {
-        let depth = depth.min(BULLETS.len() - 1);
-        let bullet = BULLETS[depth];
-        return format!("{} ", bullet);
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use crate::markdown_elements::ParsedMarkdownMermaidDiagramContents;
-    use crate::markdown_elements::ParsedMarkdownTableColumn;
-    use crate::markdown_elements::ParsedMarkdownText;
-
-    fn text(text: &str) -> MarkdownParagraphChunk {
-        MarkdownParagraphChunk::Text(ParsedMarkdownText {
-            source_range: 0..text.len(),
-            contents: SharedString::new(text),
-            highlights: Default::default(),
-            regions: Default::default(),
-        })
-    }
-
-    fn column(
-        col_span: usize,
-        row_span: usize,
-        children: Vec<MarkdownParagraphChunk>,
-    ) -> ParsedMarkdownTableColumn {
-        ParsedMarkdownTableColumn {
-            col_span,
-            row_span,
-            is_header: false,
-            children,
-            alignment: ParsedMarkdownTableAlignment::None,
-        }
-    }
-
-    fn column_with_row_span(
-        col_span: usize,
-        row_span: usize,
-        children: Vec<MarkdownParagraphChunk>,
-    ) -> ParsedMarkdownTableColumn {
-        ParsedMarkdownTableColumn {
-            col_span,
-            row_span,
-            is_header: false,
-            children,
-            alignment: ParsedMarkdownTableAlignment::None,
-        }
-    }
-
-    #[test]
-    fn test_calculate_table_columns_count() {
-        assert_eq!(0, calculate_table_columns_count(&vec![]));
-
-        assert_eq!(
-            1,
-            calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
-                column(1, 1, vec![text("column1")])
-            ])])
-        );
-
-        assert_eq!(
-            2,
-            calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
-                column(1, 1, vec![text("column1")]),
-                column(1, 1, vec![text("column2")]),
-            ])])
-        );
-
-        assert_eq!(
-            2,
-            calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
-                column(2, 1, vec![text("column1")])
-            ])])
-        );
-
-        assert_eq!(
-            3,
-            calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![
-                column(1, 1, vec![text("column1")]),
-                column(2, 1, vec![text("column2")]),
-            ])])
-        );
-
-        assert_eq!(
-            2,
-            calculate_table_columns_count(&vec![
-                ParsedMarkdownTableRow::with_columns(vec![
-                    column(1, 1, vec![text("column1")]),
-                    column(1, 1, vec![text("column2")]),
-                ]),
-                ParsedMarkdownTableRow::with_columns(vec![column(1, 1, vec![text("column1")]),])
-            ])
-        );
-
-        assert_eq!(
-            3,
-            calculate_table_columns_count(&vec![
-                ParsedMarkdownTableRow::with_columns(vec![
-                    column(1, 1, vec![text("column1")]),
-                    column(1, 1, vec![text("column2")]),
-                ]),
-                ParsedMarkdownTableRow::with_columns(vec![column(3, 3, vec![text("column1")]),])
-            ])
-        );
-    }
-
-    #[test]
-    fn test_row_span_support() {
-        assert_eq!(
-            3,
-            calculate_table_columns_count(&vec![
-                ParsedMarkdownTableRow::with_columns(vec![
-                    column_with_row_span(1, 2, vec![text("spans 2 rows")]),
-                    column(1, 1, vec![text("column2")]),
-                    column(1, 1, vec![text("column3")]),
-                ]),
-                ParsedMarkdownTableRow::with_columns(vec![
-                    // First column is covered by row span from above
-                    column(1, 1, vec![text("column2 row2")]),
-                    column(1, 1, vec![text("column3 row2")]),
-                ])
-            ])
-        );
-
-        assert_eq!(
-            4,
-            calculate_table_columns_count(&vec![
-                ParsedMarkdownTableRow::with_columns(vec![
-                    column_with_row_span(1, 3, vec![text("spans 3 rows")]),
-                    column_with_row_span(2, 1, vec![text("spans 2 cols")]),
-                    column(1, 1, vec![text("column4")]),
-                ]),
-                ParsedMarkdownTableRow::with_columns(vec![
-                    // First column covered by row span
-                    column(1, 1, vec![text("column2")]),
-                    column(1, 1, vec![text("column3")]),
-                    column(1, 1, vec![text("column4")]),
-                ]),
-                ParsedMarkdownTableRow::with_columns(vec![
-                    // First column still covered by row span
-                    column(3, 1, vec![text("spans 3 cols")]),
-                ])
-            ])
-        );
-    }
-
-    #[test]
-    fn test_list_item_prefix() {
-        assert_eq!(list_item_prefix(1, true, 0), "1. ");
-        assert_eq!(list_item_prefix(2, true, 0), "2. ");
-        assert_eq!(list_item_prefix(3, true, 0), "3. ");
-        assert_eq!(list_item_prefix(11, true, 0), "11. ");
-        assert_eq!(list_item_prefix(1, true, 1), "A. ");
-        assert_eq!(list_item_prefix(2, true, 1), "B. ");
-        assert_eq!(list_item_prefix(3, true, 1), "C. ");
-        assert_eq!(list_item_prefix(1, true, 2), "a. ");
-        assert_eq!(list_item_prefix(2, true, 2), "b. ");
-        assert_eq!(list_item_prefix(7, true, 2), "g. ");
-        assert_eq!(list_item_prefix(1, true, 1), "A. ");
-        assert_eq!(list_item_prefix(1, true, 2), "a. ");
-        assert_eq!(list_item_prefix(1, false, 0), "β€’ ");
-        assert_eq!(list_item_prefix(1, false, 1), "β—¦ ");
-        assert_eq!(list_item_prefix(1, false, 2), "β–ͺ ");
-        assert_eq!(list_item_prefix(1, false, 3), "β€£ ");
-        assert_eq!(list_item_prefix(1, false, 4), "⁃ ");
-    }
-
-    fn mermaid_contents(s: &str) -> ParsedMarkdownMermaidDiagramContents {
-        ParsedMarkdownMermaidDiagramContents {
-            contents: SharedString::from(s.to_string()),
-            scale: 1,
-        }
-    }
-
-    fn mermaid_sequence(diagrams: &[&str]) -> Vec<ParsedMarkdownMermaidDiagramContents> {
-        diagrams
-            .iter()
-            .map(|diagram| mermaid_contents(diagram))
-            .collect()
-    }
-
-    fn mermaid_fallback(
-        new_diagram: &str,
-        new_full_order: &[ParsedMarkdownMermaidDiagramContents],
-        old_full_order: &[ParsedMarkdownMermaidDiagramContents],
-        cache: &MermaidDiagramCache,
-    ) -> Option<Arc<RenderImage>> {
-        let new_content = mermaid_contents(new_diagram);
-        let idx = new_full_order
-            .iter()
-            .position(|content| content == &new_content)?;
-        MermaidState::get_fallback_image(idx, old_full_order, new_full_order.len(), cache)
-    }
-
-    fn mock_render_image() -> Arc<RenderImage> {
-        Arc::new(RenderImage::new(Vec::new()))
-    }
-
-    #[test]
-    fn test_mermaid_fallback_on_edit() {
-        let old_full_order = mermaid_sequence(&["graph A", "graph B", "graph C"]);
-        let new_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]);
-
-        let svg_b = mock_render_image();
-        let mut cache: MermaidDiagramCache = HashMap::default();
-        cache.insert(
-            mermaid_contents("graph A"),
-            CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
-        );
-        cache.insert(
-            mermaid_contents("graph B"),
-            CachedMermaidDiagram::new_for_test(Some(svg_b.clone()), None),
-        );
-        cache.insert(
-            mermaid_contents("graph C"),
-            CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
-        );
-
-        let fallback =
-            mermaid_fallback("graph B modified", &new_full_order, &old_full_order, &cache);
-
-        assert!(
-            fallback.is_some(),
-            "Should use old diagram as fallback when editing"
-        );
-        assert!(
-            Arc::ptr_eq(&fallback.unwrap(), &svg_b),
-            "Fallback should be the old diagram's SVG"
-        );
-    }
-
-    #[test]
-    fn test_mermaid_no_fallback_on_add_in_middle() {
-        let old_full_order = mermaid_sequence(&["graph A", "graph C"]);
-        let new_full_order = mermaid_sequence(&["graph A", "graph NEW", "graph C"]);
-
-        let mut cache: MermaidDiagramCache = HashMap::default();
-        cache.insert(
-            mermaid_contents("graph A"),
-            CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
-        );
-        cache.insert(
-            mermaid_contents("graph C"),
-            CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
-        );
-
-        let fallback = mermaid_fallback("graph NEW", &new_full_order, &old_full_order, &cache);
-
-        assert!(
-            fallback.is_none(),
-            "Should NOT use fallback when adding new diagram"
-        );
-    }
-
-    #[test]
-    fn test_mermaid_fallback_chains_on_rapid_edits() {
-        let old_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]);
-        let new_full_order = mermaid_sequence(&["graph A", "graph B modified again", "graph C"]);
-
-        let original_svg = mock_render_image();
-        let mut cache: MermaidDiagramCache = HashMap::default();
-        cache.insert(
-            mermaid_contents("graph A"),
-            CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
-        );
-        cache.insert(
-            mermaid_contents("graph B modified"),
-            // Still rendering, but has fallback from original "graph B"
-            CachedMermaidDiagram::new_for_test(None, Some(original_svg.clone())),
-        );
-        cache.insert(
-            mermaid_contents("graph C"),
-            CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
-        );
-
-        let fallback = mermaid_fallback(
-            "graph B modified again",
-            &new_full_order,
-            &old_full_order,
-            &cache,
-        );
-
-        assert!(
-            fallback.is_some(),
-            "Should chain fallback when previous render not complete"
-        );
-        assert!(
-            Arc::ptr_eq(&fallback.unwrap(), &original_svg),
-            "Fallback should chain through to the original SVG"
-        );
-    }
-
-    #[test]
-    fn test_mermaid_no_fallback_when_no_old_diagram_at_index() {
-        let old_full_order = mermaid_sequence(&["graph A"]);
-        let new_full_order = mermaid_sequence(&["graph A", "graph B"]);
-
-        let mut cache: MermaidDiagramCache = HashMap::default();
-        cache.insert(
-            mermaid_contents("graph A"),
-            CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
-        );
-
-        let fallback = mermaid_fallback("graph B", &new_full_order, &old_full_order, &cache);
-
-        assert!(
-            fallback.is_none(),
-            "Should NOT have fallback when adding diagram at end"
-        );
-    }
-
-    #[test]
-    fn test_mermaid_fallback_with_duplicate_blocks_edit_first() {
-        let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]);
-        let new_full_order = mermaid_sequence(&["graph A edited", "graph A", "graph B"]);
-
-        let svg_a = mock_render_image();
-        let mut cache: MermaidDiagramCache = HashMap::default();
-        cache.insert(
-            mermaid_contents("graph A"),
-            CachedMermaidDiagram::new_for_test(Some(svg_a.clone()), None),
-        );
-        cache.insert(
-            mermaid_contents("graph B"),
-            CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
-        );
-
-        let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache);
-
-        assert!(
-            fallback.is_some(),
-            "Should use old diagram as fallback when editing one of duplicate blocks"
-        );
-        assert!(
-            Arc::ptr_eq(&fallback.unwrap(), &svg_a),
-            "Fallback should be the old duplicate diagram's image"
-        );
-    }
-
-    #[test]
-    fn test_mermaid_fallback_with_duplicate_blocks_edit_second() {
-        let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]);
-        let new_full_order = mermaid_sequence(&["graph A", "graph A edited", "graph B"]);
-
-        let svg_a = mock_render_image();
-        let mut cache: MermaidDiagramCache = HashMap::default();
-        cache.insert(
-            mermaid_contents("graph A"),
-            CachedMermaidDiagram::new_for_test(Some(svg_a.clone()), None),
-        );
-        cache.insert(
-            mermaid_contents("graph B"),
-            CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None),
-        );
-
-        let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache);
-
-        assert!(
-            fallback.is_some(),
-            "Should use old diagram as fallback when editing the second duplicate block"
-        );
-        assert!(
-            Arc::ptr_eq(&fallback.unwrap(), &svg_a),
-            "Fallback should be the old duplicate diagram's image"
-        );
-    }
-}

crates/multi_buffer/Cargo.toml πŸ”—

@@ -45,6 +45,7 @@ tree-sitter.workspace = true
 ztracing.workspace = true
 tracing.workspace = true
 util.workspace = true
+unicode-segmentation.workspace = true
 
 [dev-dependencies]
 buffer_diff = { workspace = true, features = ["test-support"] }

crates/multi_buffer/src/multi_buffer.rs πŸ”—

@@ -55,6 +55,7 @@ use text::{
     subscription::{Subscription, Topic},
 };
 use theme::SyntaxTheme;
+use unicode_segmentation::UnicodeSegmentation;
 use util::post_inc;
 use ztracing::instrument;
 
@@ -7243,6 +7244,16 @@ impl MultiBufferSnapshot {
         }
         excerpt_edits
     }
+
+    /// Returns the number of graphemes in `range`.
+    ///
+    /// This counts user-visible characters like `e\u{301}` as one.
+    pub fn grapheme_count_for_range(&self, range: &Range<MultiBufferOffset>) -> usize {
+        self.text_for_range(range.clone())
+            .collect::<String>()
+            .graphemes(true)
+            .count()
+    }
 }
 
 #[cfg(any(test, feature = "test-support"))]

crates/onboarding/src/theme_preview.rs πŸ”—

@@ -87,13 +87,13 @@ impl ThemePreviewTile {
         let colors = theme.colors();
         let syntax = theme.syntax();
 
-        let keyword_color = syntax.get("keyword").color;
-        let function_color = syntax.get("function").color;
-        let string_color = syntax.get("string").color;
-        let comment_color = syntax.get("comment").color;
-        let variable_color = syntax.get("variable").color;
-        let type_color = syntax.get("type").color;
-        let punctuation_color = syntax.get("punctuation").color;
+        let keyword_color = syntax.style_for_name("keyword").and_then(|s| s.color);
+        let function_color = syntax.style_for_name("function").and_then(|s| s.color);
+        let string_color = syntax.style_for_name("string").and_then(|s| s.color);
+        let comment_color = syntax.style_for_name("comment").and_then(|s| s.color);
+        let variable_color = syntax.style_for_name("variable").and_then(|s| s.color);
+        let type_color = syntax.style_for_name("type").and_then(|s| s.color);
+        let punctuation_color = syntax.style_for_name("punctuation").and_then(|s| s.color);
 
         let syntax_colors = [
             keyword_color,

crates/outline_panel/src/outline_panel.rs πŸ”—

@@ -25,6 +25,7 @@ use gpui::{
 use itertools::Itertools;
 use language::language_settings::LanguageSettings;
 use language::{Anchor, BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem};
+
 use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious};
 use std::{
     cmp,
@@ -236,7 +237,8 @@ impl SearchState {
                         }
                         let style = chunk
                             .syntax_highlight_id
-                            .and_then(|highlight| highlight.style(&theme));
+                            .and_then(|highlight| theme.get(highlight).cloned());
+
                         if let Some(style) = style {
                             let start = context_text.len();
                             let end = start + chunk.text.len();

crates/platform_title_bar/src/platform_title_bar.rs πŸ”—

@@ -4,8 +4,8 @@ mod system_window_tabs;
 use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
 use gpui::{
     Action, AnyElement, App, Context, Decorations, Entity, Hsla, InteractiveElement, IntoElement,
-    MouseButton, ParentElement, StatefulInteractiveElement, Styled, Window, WindowButtonLayout,
-    WindowControlArea, div, px,
+    MouseButton, ParentElement, StatefulInteractiveElement, Styled, WeakEntity, Window,
+    WindowButtonLayout, WindowControlArea, div, px,
 };
 use project::DisableAiSettings;
 use settings::Settings;
@@ -15,6 +15,7 @@ use ui::{
     prelude::*,
     utils::{TRAFFIC_LIGHT_PADDING, platform_title_bar_height},
 };
+use workspace::{MultiWorkspace, SidebarRenderState, SidebarSide};
 
 use crate::{
     platforms::{platform_linux, platform_windows},
@@ -32,7 +33,7 @@ pub struct PlatformTitleBar {
     should_move: bool,
     system_window_tabs: Entity<SystemWindowTabs>,
     button_layout: Option<WindowButtonLayout>,
-    workspace_sidebar_open: bool,
+    multi_workspace: Option<WeakEntity<MultiWorkspace>>,
 }
 
 impl PlatformTitleBar {
@@ -47,10 +48,19 @@ impl PlatformTitleBar {
             should_move: false,
             system_window_tabs,
             button_layout: None,
-            workspace_sidebar_open: false,
+            multi_workspace: None,
         }
     }
 
+    pub fn with_multi_workspace(mut self, multi_workspace: WeakEntity<MultiWorkspace>) -> Self {
+        self.multi_workspace = Some(multi_workspace);
+        self
+    }
+
+    pub fn set_multi_workspace(&mut self, multi_workspace: WeakEntity<MultiWorkspace>) {
+        self.multi_workspace = Some(multi_workspace);
+    }
+
     pub fn title_bar_color(&self, window: &mut Window, cx: &mut Context<Self>) -> Hsla {
         if cfg!(any(target_os = "linux", target_os = "freebsd")) {
             if window.is_window_active() && !self.should_move {
@@ -92,13 +102,12 @@ impl PlatformTitleBar {
         SystemWindowTabs::init(cx);
     }
 
-    pub fn is_workspace_sidebar_open(&self) -> bool {
-        self.workspace_sidebar_open
-    }
-
-    pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context<Self>) {
-        self.workspace_sidebar_open = open;
-        cx.notify();
+    fn sidebar_render_state(&self, cx: &App) -> SidebarRenderState {
+        self.multi_workspace
+            .as_ref()
+            .and_then(|mw| mw.upgrade())
+            .map(|mw| mw.read(cx).sidebar_render_state(cx))
+            .unwrap_or_default()
     }
 
     pub fn is_multi_workspace_enabled(cx: &App) -> bool {
@@ -116,8 +125,7 @@ impl Render for PlatformTitleBar {
         let children = mem::take(&mut self.children);
 
         let button_layout = self.effective_button_layout(&decorations, cx);
-        let is_multiworkspace_sidebar_open =
-            PlatformTitleBar::is_multi_workspace_enabled(cx) && self.is_workspace_sidebar_open();
+        let sidebar = self.sidebar_render_state(cx);
 
         let title_bar = h_flex()
             .window_control_area(WindowControlArea::Drag)
@@ -168,7 +176,7 @@ impl Render for PlatformTitleBar {
                 if window.is_fullscreen() {
                     this.pl_2()
                 } else if self.platform_style == PlatformStyle::Mac
-                    && !is_multiworkspace_sidebar_open
+                    && !(sidebar.open && sidebar.side == SidebarSide::Left)
                 {
                     this.pl(px(TRAFFIC_LIGHT_PADDING))
                 } else if let Some(button_layout) =
@@ -186,11 +194,14 @@ impl Render for PlatformTitleBar {
             .map(|el| match decorations {
                 Decorations::Server => el,
                 Decorations::Client { tiling, .. } => el
-                    .when(!(tiling.top || tiling.right), |el| {
-                        el.rounded_tr(theme::CLIENT_SIDE_DECORATION_ROUNDING)
-                    })
                     .when(
-                        !(tiling.top || tiling.left) && !is_multiworkspace_sidebar_open,
+                        !(tiling.top || tiling.right)
+                            && !(sidebar.open && sidebar.side == SidebarSide::Right),
+                        |el| el.rounded_tr(theme::CLIENT_SIDE_DECORATION_ROUNDING),
+                    )
+                    .when(
+                        !(tiling.top || tiling.left)
+                            && !(sidebar.open && sidebar.side == SidebarSide::Left),
                         |el| el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING),
                     )
                     // this border is to avoid a transparent gap in the rounded corners

crates/project/src/git_store.rs πŸ”—

@@ -3706,6 +3706,23 @@ impl RepositorySnapshot {
         }
     }
 
+    /// The main worktree is the original checkout that other worktrees were
+    /// created from.
+    ///
+    /// For example, if you had both `~/code/zed` and `~/code/worktrees/zed-2`,
+    /// then `~/code/zed` is the main worktree and `~/code/worktrees/zed-2` is a linked worktree.
+    pub fn is_main_worktree(&self) -> bool {
+        self.work_directory_abs_path == self.original_repo_abs_path
+    }
+
+    /// Returns true if this repository is a linked worktree, that is, one that
+    /// was created from another worktree.
+    ///
+    /// This is by definition the opposite of [`Self::is_main_worktree`].
+    pub fn is_linked_worktree(&self) -> bool {
+        !self.is_main_worktree()
+    }
+
     pub fn linked_worktrees(&self) -> &[GitWorktree] {
         &self.linked_worktrees
     }

crates/project/src/lsp_store.rs πŸ”—

@@ -71,10 +71,10 @@ use http_client::HttpClient;
 use itertools::Itertools as _;
 use language::{
     Bias, BinaryStatus, Buffer, BufferRow, BufferSnapshot, CachedLspAdapter, Capability, CodeLabel,
-    Diagnostic, DiagnosticEntry, DiagnosticSet, DiagnosticSourceKind, Diff, File as _, Language,
-    LanguageName, LanguageRegistry, LocalFile, LspAdapter, LspAdapterDelegate, LspInstaller,
-    ManifestDelegate, ManifestName, ModelineSettings, Patch, PointUtf16, TextBufferSnapshot,
-    ToOffset, ToPointUtf16, Toolchain, Transaction, Unclipped,
+    CodeLabelExt, Diagnostic, DiagnosticEntry, DiagnosticSet, DiagnosticSourceKind, Diff,
+    File as _, Language, LanguageName, LanguageRegistry, LocalFile, LspAdapter, LspAdapterDelegate,
+    LspInstaller, ManifestDelegate, ManifestName, ModelineSettings, Patch, PointUtf16,
+    TextBufferSnapshot, ToOffset, ToPointUtf16, Toolchain, Transaction, Unclipped,
     language_settings::{
         AllLanguageSettings, FormatOnSave, Formatter, LanguageSettings, all_language_settings,
     },
@@ -822,15 +822,7 @@ impl LocalLspStore {
                     let adapter = adapter.clone();
                     if let Some(this) = this.upgrade() {
                         this.update(cx, |this, cx| {
-                            {
-                                let buffer = params
-                                    .uri
-                                    .to_file_path()
-                                    .map(|file_path| this.get_buffer(&file_path, cx))
-                                    .ok()
-                                    .flatten();
-                                adapter.process_diagnostics(&mut params, server_id, buffer);
-                            }
+                            adapter.process_diagnostics(&mut params, server_id);
 
                             this.merge_lsp_diagnostics(
                                 DiagnosticSourceKind::Pushed,
@@ -843,9 +835,9 @@ impl LocalLspStore {
                                     ),
                                     registration_id: None,
                                 }],
-                                |_, diagnostic, cx| match diagnostic.source_kind {
+                                |_, diagnostic, _cx| match diagnostic.source_kind {
                                     DiagnosticSourceKind::Other | DiagnosticSourceKind::Pushed => {
-                                        adapter.retain_old_diagnostic(diagnostic, cx)
+                                        adapter.retain_old_diagnostic(diagnostic)
                                     }
                                     DiagnosticSourceKind::Pulled => true,
                                 },
@@ -11206,23 +11198,6 @@ impl LspStore {
         cx.background_spawn(futures::future::join_all(tasks).map(|_| ()))
     }
 
-    fn get_buffer<'a>(&self, abs_path: &Path, cx: &'a App) -> Option<&'a Buffer> {
-        let (worktree, relative_path) =
-            self.worktree_store.read(cx).find_worktree(&abs_path, cx)?;
-
-        let project_path = ProjectPath {
-            worktree_id: worktree.read(cx).id(),
-            path: relative_path,
-        };
-
-        Some(
-            self.buffer_store()
-                .read(cx)
-                .get_by_path(&project_path)?
-                .read(cx),
-        )
-    }
-
     #[cfg(any(test, feature = "test-support"))]
     pub fn update_diagnostics(
         &mut self,

crates/project/src/toolchain_store.rs πŸ”—

@@ -4,7 +4,7 @@ use anyhow::{Context as _, Result, bail};
 
 use async_trait::async_trait;
 use collections::{BTreeMap, IndexSet};
-use fs::Fs;
+
 use gpui::{
     App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task, WeakEntity,
 };
@@ -62,7 +62,6 @@ impl ToolchainStore {
         worktree_store: Entity<WorktreeStore>,
         project_environment: Entity<ProjectEnvironment>,
         manifest_tree: Entity<ManifestTree>,
-        fs: Arc<dyn Fs>,
         cx: &mut Context<Self>,
     ) -> Self {
         let entity = cx.new(|_| LocalToolchainStore {
@@ -71,7 +70,6 @@ impl ToolchainStore {
             project_environment,
             active_toolchains: Default::default(),
             manifest_tree,
-            fs,
         });
         let _sub = cx.subscribe(&entity, |_, _, e: &ToolchainStoreEvent, cx| {
             cx.emit(e.clone())
@@ -418,7 +416,6 @@ pub struct LocalToolchainStore {
     project_environment: Entity<ProjectEnvironment>,
     active_toolchains: BTreeMap<(WorktreeId, LanguageName), BTreeMap<Arc<RelPath>, Toolchain>>,
     manifest_tree: Entity<ManifestTree>,
-    fs: Arc<dyn Fs>,
 }
 
 #[async_trait(?Send)]
@@ -507,7 +504,6 @@ impl LocalToolchainStore {
         let registry = self.languages.clone();
 
         let manifest_tree = self.manifest_tree.downgrade();
-        let fs = self.fs.clone();
 
         let environment = self.project_environment.clone();
         cx.spawn(async move |this, cx| {
@@ -554,12 +550,7 @@ impl LocalToolchainStore {
             cx.background_spawn(async move {
                 Some((
                     toolchains
-                        .list(
-                            worktree_root,
-                            relative_path.path.clone(),
-                            project_env,
-                            fs.as_ref(),
-                        )
+                        .list(worktree_root, relative_path.path.clone(), project_env)
                         .await,
                     relative_path.path,
                 ))
@@ -593,7 +584,6 @@ impl LocalToolchainStore {
     ) -> Task<Result<Toolchain>> {
         let registry = self.languages.clone();
         let environment = self.project_environment.clone();
-        let fs = self.fs.clone();
         cx.spawn(async move |_, cx| {
             let language = cx
                 .background_spawn(registry.language_for_name(&language_name.0))
@@ -612,12 +602,8 @@ impl LocalToolchainStore {
                     )
                 })
                 .await;
-            cx.background_spawn(async move {
-                toolchain_lister
-                    .resolve(path, project_env, fs.as_ref())
-                    .await
-            })
-            .await
+            cx.background_spawn(async move { toolchain_lister.resolve(path, project_env).await })
+                .await
         })
     }
 }

crates/project/tests/integration/project_tests.rs πŸ”—

@@ -11931,7 +11931,6 @@ fn python_lang(fs: Arc<FakeFs>) -> Arc<Language> {
             worktree_root: PathBuf,
             subroot_relative_path: Arc<RelPath>,
             _: Option<HashMap<String, String>>,
-            _: &dyn Fs,
         ) -> ToolchainList {
             // This lister will always return a path .venv directories within ancestors
             let ancestors = subroot_relative_path.ancestors().collect::<Vec<_>>();
@@ -11956,7 +11955,6 @@ fn python_lang(fs: Arc<FakeFs>) -> Arc<Language> {
             &self,
             _: PathBuf,
             _: Option<HashMap<String, String>>,
-            _: &dyn Fs,
         ) -> anyhow::Result<Toolchain> {
             Err(anyhow::anyhow!("Not implemented"))
         }

crates/prompt_store/src/prompts.rs πŸ”—

@@ -26,9 +26,9 @@ pub const RULES_FILE_NAMES: &[&str] = &[
     ".windsurfrules",
     ".clinerules",
     ".github/copilot-instructions.md",
-    "CLAUDE.md",
     "AGENT.md",
     "AGENTS.md",
+    "CLAUDE.md",
     "GEMINI.md",
 ];
 

crates/recent_projects/src/sidebar_recent_projects.rs πŸ”—

@@ -403,8 +403,8 @@ impl PickerDelegate for SidebarRecentProjectsDelegate {
 
         Some(
             v_flex()
-                .flex_1()
                 .p_1p5()
+                .flex_1()
                 .gap_1()
                 .border_t_1()
                 .border_color(cx.theme().colors().border_variant)
@@ -414,9 +414,10 @@ impl PickerDelegate for SidebarRecentProjectsDelegate {
                     };
                     Button::new("open_local_folder", "Add Local Project")
                         .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx))
-                        .on_click(move |_, window, cx| {
+                        .on_click(cx.listener(move |_, _, window, cx| {
+                            cx.emit(DismissEvent);
                             window.dispatch_action(open_action.boxed_clone(), cx)
-                        })
+                        }))
                 })
                 .into_any(),
         )

crates/settings/src/vscode_import.rs πŸ”—

@@ -769,6 +769,7 @@ impl VsCodeSettings {
     fn status_bar_settings_content(&self) -> Option<StatusBarSettingsContent> {
         skip_default(StatusBarSettingsContent {
             show: self.read_bool("workbench.statusBar.visible"),
+            show_active_file: None,
             active_language_button: None,
             cursor_position_button: None,
             line_endings_button: None,

crates/settings_content/Cargo.toml πŸ”—

@@ -28,9 +28,3 @@ settings_json.workspace = true
 settings_macros.workspace = true
 strum.workspace = true
 util.workspace = true
-
-# Uncomment other workspace dependencies as needed
-# assistant.workspace = true
-# client.workspace = true
-# project.workspace = true
-# settings.workspace = true

crates/settings_content/src/agent.rs πŸ”—

@@ -33,6 +33,39 @@ pub enum NewThreadLocation {
     NewWorktree,
 }
 
+/// Where to position the sidebar.
+#[derive(
+    Clone,
+    Copy,
+    Debug,
+    Default,
+    PartialEq,
+    Eq,
+    Serialize,
+    Deserialize,
+    JsonSchema,
+    MergeFrom,
+    strum::VariantArray,
+    strum::VariantNames,
+)]
+#[serde(rename_all = "snake_case")]
+pub enum SidebarDockPosition {
+    /// Always show the sidebar on the left side.
+    Left,
+    /// Always show the sidebar on the right side.
+    Right,
+    /// Show the sidebar on the same side as the agent panel.
+    #[default]
+    FollowAgent,
+}
+
+#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
+pub enum SidebarSide {
+    #[default]
+    Left,
+    Right,
+}
+
 #[with_fallible_options]
 #[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, Default)]
 pub struct AgentSettingsContent {
@@ -48,6 +81,10 @@ pub struct AgentSettingsContent {
     ///
     /// Default: right
     pub dock: Option<DockPosition>,
+    /// Where to position the sidebar.
+    ///
+    /// Default: follow_agent
+    pub sidebar_side: Option<SidebarDockPosition>,
     /// Default width in pixels when the agent panel is docked to the left or right.
     ///
     /// Default: 640
@@ -157,6 +194,10 @@ impl AgentSettingsContent {
         self.dock = Some(dock);
     }
 
+    pub fn set_sidebar_side(&mut self, position: SidebarDockPosition) {
+        self.sidebar_side = Some(position);
+    }
+
     pub fn set_model(&mut self, language_model: LanguageModelSelection) {
         self.default_model = Some(language_model)
     }

crates/settings_content/src/language.rs πŸ”—

@@ -85,7 +85,6 @@ pub enum EditPredictionProvider {
     Codestral,
     Ollama,
     OpenAiCompatibleApi,
-    Sweep,
     Mercury,
     Experimental(&'static str),
 }
@@ -106,7 +105,6 @@ impl<'de> Deserialize<'de> for EditPredictionProvider {
             Codestral,
             Ollama,
             OpenAiCompatibleApi,
-            Sweep,
             Mercury,
             Experimental(String),
         }
@@ -118,7 +116,6 @@ impl<'de> Deserialize<'de> for EditPredictionProvider {
             Content::Codestral => EditPredictionProvider::Codestral,
             Content::Ollama => EditPredictionProvider::Ollama,
             Content::OpenAiCompatibleApi => EditPredictionProvider::OpenAiCompatibleApi,
-            Content::Sweep => EditPredictionProvider::Sweep,
             Content::Mercury => EditPredictionProvider::Mercury,
             Content::Experimental(name)
                 if name == EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME =>
@@ -144,7 +141,6 @@ impl EditPredictionProvider {
             | EditPredictionProvider::Codestral
             | EditPredictionProvider::Ollama
             | EditPredictionProvider::OpenAiCompatibleApi
-            | EditPredictionProvider::Sweep
             | EditPredictionProvider::Mercury
             | EditPredictionProvider::Experimental(_) => false,
         }
@@ -155,7 +151,6 @@ impl EditPredictionProvider {
             EditPredictionProvider::Zed => Some("Zed AI"),
             EditPredictionProvider::Copilot => Some("GitHub Copilot"),
             EditPredictionProvider::Codestral => Some("Codestral"),
-            EditPredictionProvider::Sweep => Some("Sweep"),
             EditPredictionProvider::Mercury => Some("Mercury"),
             EditPredictionProvider::Experimental(_) | EditPredictionProvider::None => None,
             EditPredictionProvider::Ollama => Some("Ollama"),
@@ -181,8 +176,6 @@ pub struct EditPredictionSettingsContent {
     pub copilot: Option<CopilotSettingsContent>,
     /// Settings specific to Codestral.
     pub codestral: Option<CodestralSettingsContent>,
-    /// Settings specific to Sweep.
-    pub sweep: Option<SweepSettingsContent>,
     /// Settings specific to Ollama.
     pub ollama: Option<OllamaEditPredictionSettingsContent>,
     /// Settings specific to using custom OpenAI-compatible servers for edit prediction.
@@ -209,8 +202,7 @@ pub struct CustomEditPredictionProviderSettingsContent {
     ///
     /// Default: ""
     pub model: Option<String>,
-    /// Maximum tokens to generate for FIM models.
-    /// This setting does not apply to sweep models.
+    /// Maximum tokens to generate.
     ///
     /// Default: 256
     pub max_output_tokens: Option<u32>,
@@ -283,18 +275,6 @@ pub struct CodestralSettingsContent {
     pub api_url: Option<String>,
 }
 
-#[with_fallible_options]
-#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
-pub struct SweepSettingsContent {
-    /// When enabled, Sweep will not store edit prediction inputs or outputs.
-    /// When disabled, Sweep may collect data including buffer contents,
-    /// diagnostics, file paths, repository names, and generated predictions
-    /// to improve the service.
-    ///
-    /// Default: false
-    pub privacy_mode: Option<bool>,
-}
-
 /// Ollama model name for edit predictions.
 #[with_fallible_options]
 #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)]
@@ -327,7 +307,6 @@ pub struct OllamaEditPredictionSettingsContent {
     /// Default: none
     pub model: Option<OllamaModelName>,
     /// Maximum tokens to generate for FIM models.
-    /// This setting does not apply to sweep models.
     ///
     /// Default: 256
     pub max_output_tokens: Option<u32>,

crates/settings_content/src/settings_content.rs πŸ”—

@@ -1133,15 +1133,15 @@ pub struct WhichKeySettingsContent {
     pub delay_ms: Option<u64>,
 }
 
+// An ExtendingVec in the settings can only accumulate new values.
+//
+// This is useful for things like private files where you only want
+// to allow new values to be added.
+//
+// Consider using a HashMap<String, bool> instead of this type
+// (like auto_install_extensions) so that user settings files can both add
+// and remove values from the set.
 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
-/// An ExtendingVec in the settings can only accumulate new values.
-///
-/// This is useful for things like private files where you only want
-/// to allow new values to be added.
-///
-/// Consider using a HashMap<String, bool> instead of this type
-/// (like auto_install_extensions) so that user settings files can both add
-/// and remove values from the set.
 pub struct ExtendingVec<T>(pub Vec<T>);
 
 impl<T> Into<Vec<T>> for ExtendingVec<T> {
@@ -1161,10 +1161,10 @@ impl<T: Clone> merge_from::MergeFrom for ExtendingVec<T> {
     }
 }
 
-/// A SaturatingBool in the settings can only ever be set to true,
-/// later attempts to set it to false will be ignored.
-///
-/// Used by `disable_ai`.
+// A SaturatingBool in the settings can only ever be set to true,
+// later attempts to set it to false will be ignored.
+//
+// Used by `disable_ai`.
 #[derive(Debug, Default, Copy, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
 pub struct SaturatingBool(pub bool);
 

crates/settings_content/src/workspace.rs πŸ”—

@@ -434,6 +434,10 @@ pub struct StatusBarSettingsContent {
     /// Default: true
     #[serde(rename = "experimental.show")]
     pub show: Option<bool>,
+    /// Whether to show the name of the active file in the status bar.
+    ///
+    /// Default: false
+    pub show_active_file: Option<bool>,
     /// Whether to display the active language button in the status bar.
     ///
     /// Default: true

crates/settings_json/Cargo.toml πŸ”—

@@ -27,9 +27,3 @@ serde_path_to_error.workspace = true
 [dev-dependencies]
 unindent.workspace = true
 pretty_assertions.workspace = true
-
-# Uncomment other workspace dependencies as needed
-# assistant.workspace = true
-# client.workspace = true
-# project.workspace = true
-# settings.workspace = true

crates/settings_ui/src/page_data.rs πŸ”—

@@ -3327,7 +3327,7 @@ fn search_and_files_page() -> SettingsPage {
 }
 
 fn window_and_layout_page() -> SettingsPage {
-    fn status_bar_section() -> [SettingsPageItem; 9] {
+    fn status_bar_section() -> [SettingsPageItem; 10] {
         [
             SettingsPageItem::SectionHeader("Status Bar"),
             SettingsPageItem::SettingItem(SettingItem {
@@ -3472,6 +3472,28 @@ fn window_and_layout_page() -> SettingsPage {
                 metadata: None,
                 files: USER,
             }),
+            SettingsPageItem::SettingItem(SettingItem {
+                title: "Active File Name",
+                description: "Show the name of the active file in the status bar.",
+                field: Box::new(SettingField {
+                    json_path: Some("status_bar.show_active_file"),
+                    pick: |settings_content| {
+                        settings_content
+                            .status_bar
+                            .as_ref()?
+                            .show_active_file
+                            .as_ref()
+                    },
+                    write: |settings_content, value| {
+                        settings_content
+                            .status_bar
+                            .get_or_insert_default()
+                            .show_active_file = value;
+                    },
+                }),
+                metadata: None,
+                files: USER,
+            }),
         ]
     }
 

crates/settings_ui/src/pages/edit_prediction_provider_setup.rs πŸ”—

@@ -3,7 +3,6 @@ use edit_prediction::{
     ApiKeyState,
     mercury::{MERCURY_CREDENTIALS_URL, mercury_api_token},
     open_ai_compatible::{open_ai_compatible_api_token, open_ai_compatible_api_url},
-    sweep_ai::{SWEEP_CREDENTIALS_URL, sweep_api_token},
 };
 use edit_prediction_ui::{get_available_providers, set_completion_provider};
 use gpui::{Entity, ScrollHandle, prelude::*};
@@ -45,30 +44,6 @@ pub(crate) fn render_edit_prediction_setup_page(
             )
             .into_any_element(),
         ),
-        Some(
-            render_api_key_provider(
-                IconName::SweepAi,
-                "Sweep",
-                ApiKeyDocs::Link {
-                    dashboard_url: "https://app.sweep.dev/".into(),
-                },
-                sweep_api_token(cx),
-                |_cx| SWEEP_CREDENTIALS_URL,
-                Some(
-                    settings_window
-                        .render_sub_page_items_section(
-                            sweep_settings().iter().enumerate(),
-                            true,
-                            window,
-                            cx,
-                        )
-                        .into_any_element(),
-                ),
-                window,
-                cx,
-            )
-            .into_any_element(),
-        ),
         Some(
             render_api_key_provider(
                 IconName::AiMistral,
@@ -345,39 +320,6 @@ fn render_api_key_provider(
     })
 }
 
-fn sweep_settings() -> Box<[SettingsPageItem]> {
-    Box::new([SettingsPageItem::SettingItem(SettingItem {
-        title: "Privacy Mode",
-        description: "When enabled, Sweep will not store edit prediction inputs or outputs. When disabled, Sweep may collect data including buffer contents, diagnostics, file paths, and generated predictions to improve the service.",
-        field: Box::new(SettingField {
-            pick: |settings| {
-                settings
-                    .project
-                    .all_languages
-                    .edit_predictions
-                    .as_ref()?
-                    .sweep
-                    .as_ref()?
-                    .privacy_mode
-                    .as_ref()
-            },
-            write: |settings, value| {
-                settings
-                    .project
-                    .all_languages
-                    .edit_predictions
-                    .get_or_insert_default()
-                    .sweep
-                    .get_or_insert_default()
-                    .privacy_mode = value;
-            },
-            json_path: Some("edit_predictions.sweep.privacy_mode"),
-        }),
-        metadata: None,
-        files: USER,
-    })])
-}
-
 fn render_ollama_provider(
     settings_window: &SettingsWindow,
     window: &mut Window,

crates/sidebar/Cargo.toml πŸ”—

@@ -19,6 +19,7 @@ acp_thread.workspace = true
 action_log.workspace = true
 agent.workspace = true
 agent-client-protocol.workspace = true
+agent_settings.workspace = true
 agent_ui.workspace = true
 anyhow.workspace = true
 chrono.workspace = true

crates/sidebar/src/project_group_builder.rs πŸ”—

@@ -0,0 +1,330 @@
+//! The sidebar groups threads by a canonical path list.
+//!
+//! Threads have a path list associated with them, but this is the absolute path
+//! of whatever worktrees they were associated with. In the sidebar, we want to
+//! group all threads by their main worktree, and then we add a worktree chip to
+//! the sidebar entry when that thread is in another worktree.
+//!
+//! This module is provides the functions and structures necessary to do this
+//! lookup and mapping.
+
+use std::{
+    collections::{HashMap, HashSet},
+    path::{Path, PathBuf},
+    sync::Arc,
+};
+
+use gpui::{App, Entity};
+use ui::SharedString;
+use workspace::{MultiWorkspace, PathList, Workspace};
+
+/// Identifies a project group by a set of paths the workspaces in this group
+/// have.
+///
+/// Paths are mapped to their main worktree path first so we can group
+/// workspaces by main repos.
+#[derive(PartialEq, Eq, Hash, Clone)]
+pub struct ProjectGroupName {
+    path_list: PathList,
+}
+
+impl ProjectGroupName {
+    pub fn display_name(&self) -> SharedString {
+        let mut names = Vec::with_capacity(self.path_list.paths().len());
+        for abs_path in self.path_list.paths() {
+            if let Some(name) = abs_path.file_name() {
+                names.push(name.to_string_lossy().to_string());
+            }
+        }
+        if names.is_empty() {
+            // TODO: Can we do something better in this case?
+            "Empty Workspace".into()
+        } else {
+            names.join(", ").into()
+        }
+    }
+
+    pub fn path_list(&self) -> &PathList {
+        &self.path_list
+    }
+}
+
+#[derive(Default)]
+pub struct ProjectGroup {
+    pub workspaces: Vec<Entity<Workspace>>,
+    /// Root paths of all open workspaces in this group. Used to skip
+    /// redundant thread-store queries for linked worktrees that already
+    /// have an open workspace.
+    covered_paths: HashSet<Arc<Path>>,
+}
+
+impl ProjectGroup {
+    fn add_workspace(&mut self, workspace: &Entity<Workspace>, cx: &App) {
+        if !self.workspaces.contains(workspace) {
+            self.workspaces.push(workspace.clone());
+        }
+        for path in workspace.read(cx).root_paths(cx) {
+            self.covered_paths.insert(path);
+        }
+    }
+
+    pub fn first_workspace(&self) -> &Entity<Workspace> {
+        self.workspaces
+            .first()
+            .expect("groups always have at least one workspace")
+    }
+}
+
+pub struct ProjectGroupBuilder {
+    /// Maps git repositories' work_directory_abs_path to their original_repo_abs_path
+    directory_mappings: HashMap<PathBuf, PathBuf>,
+    project_group_names: Vec<ProjectGroupName>,
+    project_groups: Vec<ProjectGroup>,
+}
+
+impl ProjectGroupBuilder {
+    fn new() -> Self {
+        Self {
+            directory_mappings: HashMap::new(),
+            project_group_names: Vec::new(),
+            project_groups: Vec::new(),
+        }
+    }
+
+    pub fn from_multiworkspace(mw: &MultiWorkspace, cx: &App) -> Self {
+        let mut builder = Self::new();
+
+        // First pass: collect all directory mappings from every workspace
+        // so we know how to canonicalize any path (including linked
+        // worktree paths discovered by the main repo's workspace).
+        for workspace in mw.workspaces() {
+            builder.add_workspace_mappings(workspace.read(cx), cx);
+        }
+
+        // Second pass: group each workspace using canonical paths derived
+        // from the full set of mappings.
+        for workspace in mw.workspaces() {
+            let group_name = builder.canonical_workspace_paths(workspace, cx);
+            builder
+                .project_group_entry(&group_name)
+                .add_workspace(workspace, cx);
+        }
+        builder
+    }
+
+    fn project_group_entry(&mut self, name: &ProjectGroupName) -> &mut ProjectGroup {
+        match self.project_group_names.iter().position(|n| n == name) {
+            Some(idx) => &mut self.project_groups[idx],
+            None => {
+                let idx = self.project_group_names.len();
+                self.project_group_names.push(name.clone());
+                self.project_groups.push(ProjectGroup::default());
+                &mut self.project_groups[idx]
+            }
+        }
+    }
+
+    fn add_mapping(&mut self, work_directory: &Path, original_repo: &Path) {
+        let old = self
+            .directory_mappings
+            .insert(PathBuf::from(work_directory), PathBuf::from(original_repo));
+        if let Some(old) = old {
+            debug_assert_eq!(
+                &old, original_repo,
+                "all worktrees should map to the same main worktree"
+            );
+        }
+    }
+
+    pub fn add_workspace_mappings(&mut self, workspace: &Workspace, cx: &App) {
+        for repo in workspace.project().read(cx).repositories(cx).values() {
+            let snapshot = repo.read(cx).snapshot();
+
+            self.add_mapping(
+                &snapshot.work_directory_abs_path,
+                &snapshot.original_repo_abs_path,
+            );
+
+            for worktree in snapshot.linked_worktrees.iter() {
+                self.add_mapping(&worktree.path, &snapshot.original_repo_abs_path);
+            }
+        }
+    }
+
+    /// Derives the canonical group name for a workspace by canonicalizing
+    /// each of its root paths using the builder's directory mappings.
+    fn canonical_workspace_paths(
+        &self,
+        workspace: &Entity<Workspace>,
+        cx: &App,
+    ) -> ProjectGroupName {
+        let paths: Vec<_> = workspace
+            .read(cx)
+            .root_paths(cx)
+            .iter()
+            .map(|p| self.canonicalize_path(p).to_path_buf())
+            .collect();
+        ProjectGroupName {
+            path_list: PathList::new(&paths),
+        }
+    }
+
+    pub fn canonicalize_path<'a>(&'a self, path: &'a Path) -> &'a Path {
+        self.directory_mappings
+            .get(path)
+            .map(AsRef::as_ref)
+            .unwrap_or(path)
+    }
+
+    /// Whether the given group should load threads for a linked worktree at
+    /// `worktree_path`. Returns `false` if the worktree already has an open
+    /// workspace in the group (its threads are loaded via the workspace loop)
+    /// or if the worktree's canonical path list doesn't match `group_path_list`.
+    pub fn group_owns_worktree(
+        &self,
+        group: &ProjectGroup,
+        group_path_list: &PathList,
+        worktree_path: &Path,
+    ) -> bool {
+        let worktree_arc: Arc<Path> = Arc::from(worktree_path);
+        if group.covered_paths.contains(&worktree_arc) {
+            return false;
+        }
+        let canonical = self.canonicalize_path_list(&PathList::new(&[worktree_path]));
+        canonical == *group_path_list
+    }
+
+    fn canonicalize_path_list(&self, path_list: &PathList) -> PathList {
+        let paths: Vec<_> = path_list
+            .paths()
+            .iter()
+            .map(|p| self.canonicalize_path(p).to_path_buf())
+            .collect();
+        PathList::new(&paths)
+    }
+
+    pub fn groups(&self) -> impl Iterator<Item = (&ProjectGroupName, &ProjectGroup)> {
+        self.project_group_names
+            .iter()
+            .zip(self.project_groups.iter())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::sync::Arc;
+
+    use super::*;
+    use fs::FakeFs;
+    use gpui::TestAppContext;
+    use settings::SettingsStore;
+
+    fn init_test(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let settings_store = SettingsStore::test(cx);
+            cx.set_global(settings_store);
+            theme::init(theme::LoadThemes::JustBase, cx);
+        });
+    }
+
+    async fn create_fs_with_main_and_worktree(cx: &mut TestAppContext) -> Arc<FakeFs> {
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            "/project",
+            serde_json::json!({
+                ".git": {
+                    "worktrees": {
+                        "feature-a": {
+                            "commondir": "../../",
+                            "HEAD": "ref: refs/heads/feature-a",
+                        },
+                    },
+                },
+                "src": {},
+            }),
+        )
+        .await;
+        fs.insert_tree(
+            "/wt/feature-a",
+            serde_json::json!({
+                ".git": "gitdir: /project/.git/worktrees/feature-a",
+                "src": {},
+            }),
+        )
+        .await;
+        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
+            state.worktrees.push(git::repository::Worktree {
+                path: std::path::PathBuf::from("/wt/feature-a"),
+                ref_name: Some("refs/heads/feature-a".into()),
+                sha: "abc".into(),
+            });
+        })
+        .expect("git state should be set");
+        fs
+    }
+
+    #[gpui::test]
+    async fn test_main_repo_maps_to_itself(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = create_fs_with_main_and_worktree(cx).await;
+        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+        let project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
+        project
+            .update(cx, |project, cx| project.git_scans_complete(cx))
+            .await;
+
+        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
+            workspace::MultiWorkspace::test_new(project.clone(), window, cx)
+        });
+
+        multi_workspace.read_with(cx, |mw, cx| {
+            let mut canonicalizer = ProjectGroupBuilder::new();
+            for workspace in mw.workspaces() {
+                canonicalizer.add_workspace_mappings(workspace.read(cx), cx);
+            }
+
+            // The main repo path should canonicalize to itself.
+            assert_eq!(
+                canonicalizer.canonicalize_path(Path::new("/project")),
+                Path::new("/project"),
+            );
+
+            // An unknown path returns None.
+            assert_eq!(
+                canonicalizer.canonicalize_path(Path::new("/something/else")),
+                Path::new("/something/else"),
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_worktree_checkout_canonicalizes_to_main_repo(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = create_fs_with_main_and_worktree(cx).await;
+        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+        // Open the worktree checkout as its own project.
+        let project = project::Project::test(fs.clone(), ["/wt/feature-a".as_ref()], cx).await;
+        project
+            .update(cx, |project, cx| project.git_scans_complete(cx))
+            .await;
+
+        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
+            workspace::MultiWorkspace::test_new(project.clone(), window, cx)
+        });
+
+        multi_workspace.read_with(cx, |mw, cx| {
+            let mut canonicalizer = ProjectGroupBuilder::new();
+            for workspace in mw.workspaces() {
+                canonicalizer.add_workspace_mappings(workspace.read(cx), cx);
+            }
+
+            // The worktree checkout path should canonicalize to the main repo.
+            assert_eq!(
+                canonicalizer.canonicalize_path(Path::new("/wt/feature-a")),
+                Path::new("/project"),
+            );
+        });
+    }
+}

crates/sidebar/src/sidebar.rs πŸ”—

@@ -1,6 +1,7 @@
 use acp_thread::ThreadStatus;
 use action_log::DiffStats;
 use agent_client_protocol::{self as acp};
+use agent_settings::AgentSettings;
 use agent_ui::thread_metadata_store::{SidebarThreadMetadataStore, ThreadMetadata};
 use agent_ui::threads_archive_view::{
     ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp,
@@ -13,13 +14,14 @@ use db::kvp::KeyValueStore;
 use editor::Editor;
 use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _};
 use gpui::{
-    Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, ListState, Pixels,
-    Render, SharedString, Task, WeakEntity, Window, WindowHandle, list, prelude::*, px,
+    Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, KeyContext, ListState,
+    Pixels, Render, SharedString, Task, WeakEntity, Window, WindowHandle, list, prelude::*, px,
 };
+
 use menu::{
     Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious,
 };
-use project::{AgentId, Event as ProjectEvent, linked_worktree_short_name};
+use project::{Event as ProjectEvent, linked_worktree_short_name};
 use recent_projects::sidebar_recent_projects::SidebarRecentProjects;
 use ui::utils::platform_title_bar_height;
 
@@ -27,9 +29,7 @@ use serde::{Deserialize, Serialize};
 use settings::Settings as _;
 use std::collections::{HashMap, HashSet};
 use std::mem;
-use std::path::Path;
 use std::rc::Rc;
-use std::sync::Arc;
 use theme::ActiveTheme;
 use ui::{
     AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding,
@@ -39,8 +39,8 @@ use util::path_list::PathList;
 use util::{ResultExt as _, TryFutureExt as _};
 use workspace::{
     AddFolderToProject, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Open,
-    SerializedPathList, Sidebar as WorkspaceSidebar, ToggleWorkspaceSidebar, Workspace,
-    WorkspaceId,
+    SerializedPathList, Sidebar as WorkspaceSidebar, SidebarSide, ToggleWorkspaceSidebar,
+    Workspace, WorkspaceId, sidebar_side_context_menu,
 };
 
 use zed_actions::OpenRecent;
@@ -48,6 +48,10 @@ use zed_actions::editor::{MoveDown, MoveUp};
 
 use zed_actions::agents_sidebar::FocusSidebarFilter;
 
+use crate::project_group_builder::ProjectGroupBuilder;
+
+mod project_group_builder;
+
 gpui::actions!(
     agents_sidebar,
     [
@@ -127,6 +131,24 @@ struct ThreadEntry {
     diff_stats: DiffStats,
 }
 
+impl ThreadEntry {
+    /// Updates this thread entry with active thread information.
+    ///
+    /// The existing [`ThreadEntry`] was likely deserialized from the database
+    /// but if we have a correspond thread already loaded we want to apply the
+    /// live information.
+    fn apply_active_info(&mut self, info: &ActiveThreadInfo) {
+        self.session_info.title = Some(info.title.clone());
+        self.status = info.status;
+        self.icon = info.icon;
+        self.icon_from_external_svg = info.icon_from_external_svg.clone();
+        self.is_live = true;
+        self.is_background = info.is_background;
+        self.is_title_generating = info.is_title_generating;
+        self.diff_stats = info.diff_stats;
+    }
+}
+
 #[derive(Clone)]
 enum ListEntry {
     ProjectHeader {
@@ -201,21 +223,17 @@ fn fuzzy_match_positions(query: &str, candidate: &str) -> Option<Vec<usize>> {
 fn root_repository_snapshots(
     workspace: &Entity<Workspace>,
     cx: &App,
-) -> Vec<project::git_store::RepositorySnapshot> {
+) -> impl Iterator<Item = project::git_store::RepositorySnapshot> {
     let path_list = workspace_path_list(workspace, cx);
     let project = workspace.read(cx).project().read(cx);
-    project
-        .repositories(cx)
-        .values()
-        .filter_map(|repo| {
-            let snapshot = repo.read(cx).snapshot();
-            let is_root = path_list
-                .paths()
-                .iter()
-                .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref());
-            is_root.then_some(snapshot)
-        })
-        .collect()
+    project.repositories(cx).values().filter_map(move |repo| {
+        let snapshot = repo.read(cx).snapshot();
+        let is_root = path_list
+            .paths()
+            .iter()
+            .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref());
+        is_root.then_some(snapshot)
+    })
 }
 
 fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
@@ -254,6 +272,7 @@ fn load_collapsed_groups(kvp: &KeyValueStore) -> HashSet<PathList> {
 }
 
 /// The sidebar re-derives its entire entry list from scratch on every
+
 /// change via `update_entries` β†’ `rebuild_contents`. Avoid adding
 /// incremental or inter-event coordination state β€” if something can
 /// be computed from the current world state, compute it in the rebuild.
@@ -575,61 +594,21 @@ impl Sidebar {
         result
     }
 
-    fn all_thread_infos_for_workspace(
-        workspace: &Entity<Workspace>,
-        cx: &App,
-    ) -> Vec<ActiveThreadInfo> {
-        let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
-            return Vec::new();
-        };
-        let agent_panel_ref = agent_panel.read(cx);
-
-        agent_panel_ref
-            .parent_threads(cx)
-            .into_iter()
-            .map(|thread_view| {
-                let thread_view_ref = thread_view.read(cx);
-                let thread = thread_view_ref.thread.read(cx);
-
-                let icon = thread_view_ref.agent_icon;
-                let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
-                let title = thread
-                    .title()
-                    .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
-                let is_native = thread_view_ref.as_native_thread(cx).is_some();
-                let is_title_generating = is_native && thread.has_provisional_title();
-                let session_id = thread.session_id().clone();
-                let is_background = agent_panel_ref.is_background_thread(&session_id);
-
-                let status = if thread.is_waiting_for_confirmation() {
-                    AgentThreadStatus::WaitingForConfirmation
-                } else if thread.had_error() {
-                    AgentThreadStatus::Error
-                } else {
-                    match thread.status() {
-                        ThreadStatus::Generating => AgentThreadStatus::Running,
-                        ThreadStatus::Idle => AgentThreadStatus::Completed,
-                    }
-                };
-
-                let diff_stats = thread.action_log().read(cx).diff_stats(cx);
-
-                ActiveThreadInfo {
-                    session_id,
-                    title,
-                    status,
-                    icon,
-                    icon_from_external_svg,
-                    is_background,
-                    is_title_generating,
-                    diff_stats,
-                }
-            })
-            .collect()
-    }
-
-    /// When modifying this thread, aim for a single forward pass over workspaces
-    /// and threads plus an O(T log T) sort. Avoid adding extra scans over the data.
+    /// Rebuilds the sidebar contents from current workspace and thread state.
+    ///
+    /// Uses [`ProjectGroupBuilder`] to group workspaces by their main git
+    /// repository, then populates thread entries from the metadata store and
+    /// merges live thread info from active agent panels.
+    ///
+    /// Aim for a single forward pass over workspaces and threads plus an
+    /// O(T log T) sort. Avoid adding extra scans over the data.
+    ///
+    /// Properties:
+    ///
+    /// - Should always show every workspace in the multiworkspace
+    ///     - If you have no threads, and two workspaces for the worktree and the main workspace, make sure at least one is shown
+    /// - Should always show every thread, associated with each workspace in the multiworkspace
+    /// - After every build_contents, our "active" state should exactly match the current workspace's, current agent panel's current thread.
     fn rebuild_contents(&mut self, cx: &App) {
         let Some(multi_workspace) = self.multi_workspace.upgrade() else {
             return;
@@ -638,7 +617,6 @@ impl Sidebar {
         let workspaces = mw.workspaces().to_vec();
         let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned();
 
-        // Build a lookup for agent icons from the first workspace's AgentServerStore.
         let agent_server_store = workspaces
             .first()
             .map(|ws| ws.read(cx).project().read(cx).agent_server_store().clone());
@@ -693,118 +671,62 @@ impl Sidebar {
         let mut current_session_ids: HashSet<acp::SessionId> = HashSet::new();
         let mut project_header_indices: Vec<usize> = Vec::new();
 
-        // Identify absorbed workspaces in a single pass. A workspace is
-        // "absorbed" when it points at a git worktree checkout whose main
-        // repo is open as another workspace β€” its threads appear under the
-        // main repo's header instead of getting their own.
-        let mut main_repo_workspace: HashMap<Arc<Path>, usize> = HashMap::new();
-        let mut absorbed: HashMap<usize, (usize, SharedString)> = HashMap::new();
-        let mut pending: HashMap<Arc<Path>, Vec<(usize, SharedString, Arc<Path>)>> = HashMap::new();
-        let mut absorbed_workspace_by_path: HashMap<Arc<Path>, usize> = HashMap::new();
-        let workspace_indices_by_path: HashMap<Arc<Path>, Vec<usize>> = workspaces
-            .iter()
-            .enumerate()
-            .flat_map(|(index, workspace)| {
-                let paths = workspace_path_list(workspace, cx).paths().to_vec();
-                paths
-                    .into_iter()
-                    .map(move |path| (Arc::from(path.as_path()), index))
-            })
-            .fold(HashMap::new(), |mut map, (path, index)| {
-                map.entry(path).or_default().push(index);
-                map
-            });
-
-        for (i, workspace) in workspaces.iter().enumerate() {
-            for snapshot in root_repository_snapshots(workspace, cx) {
-                if snapshot.work_directory_abs_path == snapshot.original_repo_abs_path {
-                    main_repo_workspace
-                        .entry(snapshot.work_directory_abs_path.clone())
-                        .or_insert(i);
-
-                    for git_worktree in snapshot.linked_worktrees() {
-                        let worktree_path: Arc<Path> = Arc::from(git_worktree.path.as_path());
-                        if let Some(worktree_indices) =
-                            workspace_indices_by_path.get(worktree_path.as_ref())
-                        {
-                            for &worktree_idx in worktree_indices {
-                                if worktree_idx == i {
-                                    continue;
-                                }
-
-                                let worktree_name = linked_worktree_short_name(
-                                    &snapshot.original_repo_abs_path,
-                                    &git_worktree.path,
-                                )
-                                .unwrap_or_default();
-                                absorbed.insert(worktree_idx, (i, worktree_name.clone()));
-                                absorbed_workspace_by_path
-                                    .insert(worktree_path.clone(), worktree_idx);
-                            }
-                        }
-                    }
-
-                    if let Some(waiting) = pending.remove(&snapshot.work_directory_abs_path) {
-                        for (ws_idx, name, ws_path) in waiting {
-                            absorbed.insert(ws_idx, (i, name));
-                            absorbed_workspace_by_path.insert(ws_path, ws_idx);
-                        }
-                    }
-                } else {
-                    let name: SharedString = snapshot
-                        .work_directory_abs_path
-                        .file_name()
-                        .unwrap_or_default()
-                        .to_string_lossy()
-                        .to_string()
-                        .into();
-                    if let Some(&main_idx) =
-                        main_repo_workspace.get(&snapshot.original_repo_abs_path)
-                    {
-                        absorbed.insert(i, (main_idx, name));
-                        absorbed_workspace_by_path
-                            .insert(snapshot.work_directory_abs_path.clone(), i);
-                    } else {
-                        pending
-                            .entry(snapshot.original_repo_abs_path.clone())
-                            .or_default()
-                            .push((i, name, snapshot.work_directory_abs_path.clone()));
-                    }
-                }
-            }
-        }
+        // Use ProjectGroupBuilder to canonically group workspaces by their
+        // main git repository. This replaces the manual absorbed-workspace
+        // detection that was here before.
+        let project_groups = ProjectGroupBuilder::from_multiworkspace(mw, cx);
 
         let has_open_projects = workspaces
             .iter()
             .any(|ws| !workspace_path_list(ws, cx).paths().is_empty());
 
-        let active_ws_index = active_workspace
-            .as_ref()
-            .and_then(|active| workspaces.iter().position(|ws| ws == active));
-
-        for (ws_index, workspace) in workspaces.iter().enumerate() {
-            if absorbed.contains_key(&ws_index) {
-                continue;
+        let resolve_agent = |row: &ThreadMetadata| -> (Agent, IconName, Option<SharedString>) {
+            match &row.agent_id {
+                None => (Agent::NativeAgent, IconName::ZedAgent, None),
+                Some(id) => {
+                    let custom_icon = agent_server_store
+                        .as_ref()
+                        .and_then(|store| store.read(cx).agent_icon(id));
+                    (
+                        Agent::Custom { id: id.clone() },
+                        IconName::Terminal,
+                        custom_icon,
+                    )
+                }
             }
+        };
 
-            let path_list = workspace_path_list(workspace, cx);
+        for (group_name, group) in project_groups.groups() {
+            let path_list = group_name.path_list().clone();
             if path_list.paths().is_empty() {
                 continue;
             }
 
-            let label = workspace_label_from_path_list(&path_list);
+            let label = group_name.display_name();
 
             let is_collapsed = self.collapsed_groups.contains(&path_list);
             let should_load_threads = !is_collapsed || !query.is_empty();
 
-            let is_active = active_ws_index.is_some_and(|active_idx| {
-                active_idx == ws_index
-                    || absorbed
-                        .get(&active_idx)
-                        .is_some_and(|(main_idx, _)| *main_idx == ws_index)
-            });
-
-            let mut live_infos = Self::all_thread_infos_for_workspace(workspace, cx);
+            let is_active = active_workspace
+                .as_ref()
+                .is_some_and(|active| group.workspaces.contains(active));
+
+            // Pick a representative workspace for the group: prefer the active
+            // workspace if it belongs to this group, otherwise use the first.
+            //
+            // This is the workspace that will be activated by the project group
+            // header.
+            let representative_workspace = active_workspace
+                .as_ref()
+                .filter(|_| is_active)
+                .unwrap_or_else(|| group.first_workspace());
+
+            // Collect live thread infos from all workspaces in this group.
+            let live_infos: Vec<_> = group
+                .workspaces
+                .iter()
+                .flat_map(|ws| all_thread_infos_for_workspace(ws, cx))
+                .collect();
 
             let mut threads: Vec<ThreadEntry> = Vec::new();
             let mut has_running_threads = false;
@@ -812,139 +734,124 @@ impl Sidebar {
 
             if should_load_threads {
                 let mut seen_session_ids: HashSet<acp::SessionId> = HashSet::new();
-
-                // Read threads from the store cache for this workspace's path list.
                 let thread_store = SidebarThreadMetadataStore::global(cx);
-                let workspace_rows: Vec<_> =
-                    thread_store.read(cx).entries_for_path(&path_list).collect();
-                for row in workspace_rows {
-                    seen_session_ids.insert(row.session_id.clone());
-                    let (agent, icon, icon_from_external_svg) = match &row.agent_id {
-                        None => (Agent::NativeAgent, IconName::ZedAgent, None),
-                        Some(id) => {
-                            let custom_icon = agent_server_store
-                                .as_ref()
-                                .and_then(|store| store.read(cx).agent_icon(&id));
-                            (
-                                Agent::Custom { id: id.clone() },
-                                IconName::Terminal,
-                                custom_icon,
-                            )
-                        }
-                    };
-                    threads.push(ThreadEntry {
-                        agent,
-                        session_info: acp_thread::AgentSessionInfo {
-                            session_id: row.session_id.clone(),
-                            work_dirs: None,
-                            title: Some(row.title.clone()),
-                            updated_at: Some(row.updated_at),
-                            created_at: row.created_at,
-                            meta: None,
-                        },
-                        icon,
-                        icon_from_external_svg,
-                        status: AgentThreadStatus::default(),
-                        workspace: ThreadEntryWorkspace::Open(workspace.clone()),
-                        is_live: false,
-                        is_background: false,
-                        is_title_generating: false,
-                        highlight_positions: Vec::new(),
-                        worktree_name: None,
-                        worktree_full_path: None,
-                        worktree_highlight_positions: Vec::new(),
-                        diff_stats: DiffStats::default(),
-                    });
-                }
 
-                // Load threads from linked git worktrees of this workspace's repos.
-                {
-                    let mut linked_worktree_queries: Vec<(PathList, SharedString, Arc<Path>)> =
-                        Vec::new();
-                    for snapshot in root_repository_snapshots(workspace, cx) {
-                        if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path {
-                            continue;
-                        }
+                // Load threads from each workspace in the group.
+                for workspace in &group.workspaces {
+                    let ws_path_list = workspace_path_list(workspace, cx);
+
+                    // Determine if this workspace covers a git worktree (its
+                    // path canonicalizes to the main repo, not itself). If so,
+                    // threads from it get a worktree chip in the sidebar.
+                    let worktree_info: Option<(SharedString, SharedString)> =
+                        ws_path_list.paths().first().and_then(|path| {
+                            let canonical = project_groups.canonicalize_path(path);
+                            if canonical != path.as_path() {
+                                let name =
+                                    linked_worktree_short_name(canonical, path).unwrap_or_default();
+                                let full_path: SharedString = path.display().to_string().into();
+                                Some((name, full_path))
+                            } else {
+                                None
+                            }
+                        });
 
-                        let main_worktree_path = snapshot.original_repo_abs_path.clone();
-
-                        for git_worktree in snapshot.linked_worktrees() {
-                            let worktree_name =
-                                linked_worktree_short_name(&main_worktree_path, &git_worktree.path)
-                                    .unwrap_or_default();
-                            linked_worktree_queries.push((
-                                PathList::new(std::slice::from_ref(&git_worktree.path)),
-                                worktree_name,
-                                Arc::from(git_worktree.path.as_path()),
-                            ));
+                    let workspace_threads: Vec<_> = thread_store
+                        .read(cx)
+                        .entries_for_path(&ws_path_list)
+                        .collect();
+                    for thread in workspace_threads {
+                        if !seen_session_ids.insert(thread.session_id.clone()) {
+                            continue;
                         }
+                        let (agent, icon, icon_from_external_svg) = resolve_agent(&thread);
+                        threads.push(ThreadEntry {
+                            agent,
+                            session_info: acp_thread::AgentSessionInfo {
+                                session_id: thread.session_id.clone(),
+                                work_dirs: None,
+                                title: Some(thread.title.clone()),
+                                updated_at: Some(thread.updated_at),
+                                created_at: thread.created_at,
+                                meta: None,
+                            },
+                            icon,
+                            icon_from_external_svg,
+                            status: AgentThreadStatus::default(),
+                            workspace: ThreadEntryWorkspace::Open(workspace.clone()),
+                            is_live: false,
+                            is_background: false,
+                            is_title_generating: false,
+                            highlight_positions: Vec::new(),
+                            worktree_name: worktree_info.as_ref().map(|(name, _)| name.clone()),
+                            worktree_full_path: worktree_info
+                                .as_ref()
+                                .map(|(_, path)| path.clone()),
+                            worktree_highlight_positions: Vec::new(),
+                            diff_stats: DiffStats::default(),
+                        });
                     }
+                }
 
-                    for (worktree_path_list, worktree_name, worktree_path) in
-                        &linked_worktree_queries
-                    {
-                        let target_workspace =
-                            match absorbed_workspace_by_path.get(worktree_path.as_ref()) {
-                                Some(&idx) => {
-                                    live_infos.extend(Self::all_thread_infos_for_workspace(
-                                        &workspaces[idx],
-                                        cx,
-                                    ));
-                                    ThreadEntryWorkspace::Open(workspaces[idx].clone())
-                                }
-                                None => ThreadEntryWorkspace::Closed(worktree_path_list.clone()),
-                            };
+                // Load threads from linked git worktrees that don't have an
+                // open workspace in this group. Only include worktrees that
+                // belong to this group (not shared with another group).
+                let linked_worktree_path_lists = group
+                    .workspaces
+                    .iter()
+                    .flat_map(|ws| root_repository_snapshots(ws, cx))
+                    .filter(|snapshot| !snapshot.is_linked_worktree())
+                    .flat_map(|snapshot| {
+                        snapshot
+                            .linked_worktrees()
+                            .iter()
+                            .filter(|wt| {
+                                project_groups.group_owns_worktree(group, &path_list, &wt.path)
+                            })
+                            .map(|wt| PathList::new(std::slice::from_ref(&wt.path)))
+                            .collect::<Vec<_>>()
+                    });
 
-                        let worktree_rows: Vec<_> = thread_store
-                            .read(cx)
-                            .entries_for_path(worktree_path_list)
-                            .collect();
-                        for row in worktree_rows {
-                            if !seen_session_ids.insert(row.session_id.clone()) {
-                                continue;
-                            }
-                            let (agent, icon, icon_from_external_svg) = match &row.agent_id {
-                                None => (Agent::NativeAgent, IconName::ZedAgent, None),
-                                Some(name) => {
-                                    let custom_icon =
-                                        agent_server_store.as_ref().and_then(|store| {
-                                            store.read(cx).agent_icon(&AgentId(name.clone().into()))
-                                        });
-                                    (
-                                        Agent::Custom {
-                                            id: AgentId::new(name.clone()),
-                                        },
-                                        IconName::Terminal,
-                                        custom_icon,
-                                    )
-                                }
-                            };
-                            threads.push(ThreadEntry {
-                                agent,
-                                session_info: acp_thread::AgentSessionInfo {
-                                    session_id: row.session_id.clone(),
-                                    work_dirs: None,
-                                    title: Some(row.title.clone()),
-                                    updated_at: Some(row.updated_at),
-                                    created_at: row.created_at,
-                                    meta: None,
-                                },
-                                icon,
-                                icon_from_external_svg,
-                                status: AgentThreadStatus::default(),
-                                workspace: target_workspace.clone(),
-                                is_live: false,
-                                is_background: false,
-                                is_title_generating: false,
-                                highlight_positions: Vec::new(),
-                                worktree_name: Some(worktree_name.clone()),
-                                worktree_full_path: Some(
-                                    worktree_path.display().to_string().into(),
-                                ),
-                                worktree_highlight_positions: Vec::new(),
-                                diff_stats: DiffStats::default(),
-                            });
+                for worktree_path_list in linked_worktree_path_lists {
+                    for row in thread_store.read(cx).entries_for_path(&worktree_path_list) {
+                        if !seen_session_ids.insert(row.session_id.clone()) {
+                            continue;
                         }
+                        let worktree_info = row.folder_paths.paths().first().and_then(|path| {
+                            let canonical = project_groups.canonicalize_path(path);
+                            if canonical != path.as_path() {
+                                let name =
+                                    linked_worktree_short_name(canonical, path).unwrap_or_default();
+                                let full_path: SharedString = path.display().to_string().into();
+                                Some((name, full_path))
+                            } else {
+                                None
+                            }
+                        });
+                        let (agent, icon, icon_from_external_svg) = resolve_agent(&row);
+                        threads.push(ThreadEntry {
+                            agent,
+                            session_info: acp_thread::AgentSessionInfo {
+                                session_id: row.session_id.clone(),
+                                work_dirs: None,
+                                title: Some(row.title.clone()),
+                                updated_at: Some(row.updated_at),
+                                created_at: row.created_at,
+                                meta: None,
+                            },
+                            icon,
+                            icon_from_external_svg,
+                            status: AgentThreadStatus::default(),
+                            workspace: ThreadEntryWorkspace::Closed(row.folder_paths.clone()),
+                            is_live: false,
+                            is_background: false,
+                            is_title_generating: false,
+                            highlight_positions: Vec::new(),
+                            worktree_name: worktree_info.as_ref().map(|(name, _)| name.clone()),
+                            worktree_full_path: worktree_info.map(|(_, path)| path),
+                            worktree_highlight_positions: Vec::new(),
+                            diff_stats: DiffStats::default(),
+                        });
                     }
                 }
 
@@ -965,19 +872,12 @@ impl Sidebar {
                 // Merge live info into threads and update notification state
                 // in a single pass.
                 for thread in &mut threads {
-                    let session_id = &thread.session_info.session_id;
-
-                    if let Some(info) = live_info_by_session.get(session_id) {
-                        thread.session_info.title = Some(info.title.clone());
-                        thread.status = info.status;
-                        thread.icon = info.icon;
-                        thread.icon_from_external_svg = info.icon_from_external_svg.clone();
-                        thread.is_live = true;
-                        thread.is_background = info.is_background;
-                        thread.is_title_generating = info.is_title_generating;
-                        thread.diff_stats = info.diff_stats;
+                    if let Some(info) = live_info_by_session.get(&thread.session_info.session_id) {
+                        thread.apply_active_info(info);
                     }
 
+                    let session_id = &thread.session_info.session_id;
+
                     let is_thread_workspace_active = match &thread.workspace {
                         ThreadEntryWorkspace::Open(thread_workspace) => active_workspace
                             .as_ref()
@@ -1003,7 +903,7 @@ impl Sidebar {
                     b_time.cmp(&a_time)
                 });
             } else {
-                for info in &live_infos {
+                for info in live_infos {
                     if info.status == AgentThreadStatus::Running {
                         has_running_threads = true;
                     }
@@ -1051,7 +951,7 @@ impl Sidebar {
                 entries.push(ListEntry::ProjectHeader {
                     path_list: path_list.clone(),
                     label,
-                    workspace: workspace.clone(),
+                    workspace: representative_workspace.clone(),
                     highlight_positions: workspace_highlight_positions,
                     has_running_threads,
                     waiting_thread_count,
@@ -1075,7 +975,7 @@ impl Sidebar {
                 entries.push(ListEntry::ProjectHeader {
                     path_list: path_list.clone(),
                     label,
-                    workspace: workspace.clone(),
+                    workspace: representative_workspace.clone(),
                     highlight_positions: Vec::new(),
                     has_running_threads,
                     waiting_thread_count,
@@ -1089,7 +989,7 @@ impl Sidebar {
                 if show_new_thread_entry {
                     entries.push(ListEntry::NewThread {
                         path_list: path_list.clone(),
-                        workspace: workspace.clone(),
+                        workspace: representative_workspace.clone(),
                         is_active_draft: is_draft_for_workspace,
                     });
                 }
@@ -1698,7 +1598,7 @@ impl Sidebar {
             true,
             &path_list,
             &label,
-            &workspace,
+            workspace,
             &highlight_positions,
             *has_running_threads,
             *waiting_thread_count,
@@ -1752,7 +1652,7 @@ impl Sidebar {
         let mut known_worktree_paths: HashSet<std::path::PathBuf> = HashSet::new();
         for workspace in &workspaces {
             for snapshot in root_repository_snapshots(workspace, cx) {
-                if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path {
+                if snapshot.is_linked_worktree() {
                     continue;
                 }
                 for git_worktree in snapshot.linked_worktrees() {
@@ -1771,12 +1671,10 @@ impl Sidebar {
             if path_list.paths().len() != 1 {
                 continue;
             }
-            let should_prune = root_repository_snapshots(workspace, cx)
-                .iter()
-                .any(|snapshot| {
-                    snapshot.work_directory_abs_path != snapshot.original_repo_abs_path
-                        && !known_worktree_paths.contains(snapshot.work_directory_abs_path.as_ref())
-                });
+            let should_prune = root_repository_snapshots(workspace, cx).any(|snapshot| {
+                snapshot.is_linked_worktree()
+                    && !known_worktree_paths.contains(snapshot.work_directory_abs_path.as_ref())
+            });
             if should_prune {
                 to_remove.push(workspace.clone());
             }
@@ -1866,6 +1764,21 @@ impl Sidebar {
         self.update_entries(cx);
     }
 
+    fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
+        let mut dispatch_context = KeyContext::new_with_defaults();
+        dispatch_context.add("ThreadsSidebar");
+        dispatch_context.add("menu");
+
+        let identifier = if self.filter_editor.focus_handle(cx).is_focused(window) {
+            "searching"
+        } else {
+            "not_searching"
+        };
+
+        dispatch_context.add(identifier);
+        dispatch_context
+    }
+
     fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         if !self.focus_handle.is_focused(window) {
             return;
@@ -2997,7 +2910,9 @@ impl Sidebar {
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
         let has_query = self.has_filter_query(cx);
-        let traffic_lights = cfg!(target_os = "macos") && !window.is_fullscreen();
+        let sidebar_on_left = self.side(cx) == SidebarSide::Left;
+        let traffic_lights =
+            cfg!(target_os = "macos") && !window.is_fullscreen() && sidebar_on_left;
         let header_height = platform_title_bar_height(window);
 
         h_flex()
@@ -3051,41 +2966,93 @@ impl Sidebar {
     }
 
     fn render_sidebar_toggle_button(&self, _cx: &mut Context<Self>) -> impl IntoElement {
-        IconButton::new("sidebar-close-toggle", IconName::ThreadsSidebarLeftOpen)
-            .icon_size(IconSize::Small)
-            .tooltip(Tooltip::element(move |_window, cx| {
-                v_flex()
-                    .gap_1()
-                    .child(
-                        h_flex()
-                            .gap_2()
-                            .justify_between()
-                            .child(Label::new("Toggle Sidebar"))
-                            .child(KeyBinding::for_action(&ToggleWorkspaceSidebar, cx)),
-                    )
-                    .child(
-                        h_flex()
-                            .pt_1()
-                            .gap_2()
-                            .border_t_1()
-                            .border_color(cx.theme().colors().border_variant)
-                            .justify_between()
-                            .child(Label::new("Focus Sidebar"))
-                            .child(KeyBinding::for_action(&FocusWorkspaceSidebar, cx)),
-                    )
-                    .into_any_element()
-            }))
-            .on_click(|_, window, cx| {
-                if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
-                    multi_workspace.update(cx, |multi_workspace, cx| {
-                        multi_workspace.close_sidebar(window, cx);
-                    });
-                }
+        let on_right = AgentSettings::get_global(_cx).sidebar_side() == SidebarSide::Right;
+
+        sidebar_side_context_menu("sidebar-toggle-menu", _cx)
+            .anchor(if on_right {
+                gpui::Corner::BottomRight
+            } else {
+                gpui::Corner::BottomLeft
+            })
+            .attach(if on_right {
+                gpui::Corner::TopRight
+            } else {
+                gpui::Corner::TopLeft
+            })
+            .trigger(move |_is_active, _window, _cx| {
+                let icon = if on_right {
+                    IconName::ThreadsSidebarRightOpen
+                } else {
+                    IconName::ThreadsSidebarLeftOpen
+                };
+                IconButton::new("sidebar-close-toggle", icon)
+                    .icon_size(IconSize::Small)
+                    .tooltip(Tooltip::element(move |_window, cx| {
+                        v_flex()
+                            .gap_1()
+                            .child(
+                                h_flex()
+                                    .gap_2()
+                                    .justify_between()
+                                    .child(Label::new("Toggle Sidebar"))
+                                    .child(KeyBinding::for_action(&ToggleWorkspaceSidebar, cx)),
+                            )
+                            .child(
+                                h_flex()
+                                    .pt_1()
+                                    .gap_2()
+                                    .border_t_1()
+                                    .border_color(cx.theme().colors().border_variant)
+                                    .justify_between()
+                                    .child(Label::new("Focus Sidebar"))
+                                    .child(KeyBinding::for_action(&FocusWorkspaceSidebar, cx)),
+                            )
+                            .into_any_element()
+                    }))
+                    .on_click(|_, window, cx| {
+                        if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
+                            multi_workspace.update(cx, |multi_workspace, cx| {
+                                multi_workspace.close_sidebar(window, cx);
+                            });
+                        }
+                    })
             })
     }
-}
 
-impl Sidebar {
+    fn render_sidebar_bottom_bar(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
+        let on_right = self.side(cx) == SidebarSide::Right;
+        let is_archive = matches!(self.view, SidebarView::Archive(..));
+        let action_buttons = h_flex()
+            .gap_1()
+            .child(
+                IconButton::new("archive", IconName::Archive)
+                    .icon_size(IconSize::Small)
+                    .toggle_state(is_archive)
+                    .tooltip(move |_, cx| {
+                        Tooltip::for_action("Toggle Archived Threads", &ToggleArchive, cx)
+                    })
+                    .on_click(cx.listener(|this, _, window, cx| {
+                        this.toggle_archive(&ToggleArchive, window, cx);
+                    })),
+            )
+            .child(self.render_recent_projects_button(cx));
+        let border_color = cx.theme().colors().border;
+        let toggle_button = self.render_sidebar_toggle_button(cx);
+
+        let bar = h_flex()
+            .p_1()
+            .gap_1()
+            .justify_between()
+            .border_t_1()
+            .border_color(border_color);
+
+        if on_right {
+            bar.child(action_buttons).child(toggle_button)
+        } else {
+            bar.child(toggle_button).child(action_buttons)
+        }
+    }
+
     fn toggle_archive(&mut self, _: &ToggleArchive, window: &mut Window, cx: &mut Context<Self>) {
         match &self.view {
             SidebarView::ThreadList => self.show_archive(window, cx),
@@ -3177,6 +3144,10 @@ impl WorkspaceSidebar for Sidebar {
         matches!(self.view, SidebarView::ThreadList)
     }
 
+    fn side(&self, cx: &App) -> SidebarSide {
+        AgentSettings::get_global(cx).sidebar_side()
+    }
+
     fn prepare_for_focus(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
         self.selection = None;
         cx.notify();
@@ -3205,7 +3176,7 @@ impl Render for Sidebar {
 
         v_flex()
             .id("workspace-sidebar")
-            .key_context("ThreadsSidebar")
+            .key_context(self.dispatch_context(window, cx))
             .track_focus(&self.focus_handle)
             .on_action(cx.listener(Self::select_next))
             .on_action(cx.listener(Self::select_previous))
@@ -3231,7 +3202,8 @@ impl Render for Sidebar {
             .h_full()
             .w(self.width)
             .bg(bg)
-            .border_r_1()
+            .when(self.side(cx) == SidebarSide::Left, |el| el.border_r_1())
+            .when(self.side(cx) == SidebarSide::Right, |el| el.border_l_1())
             .border_color(color.border)
             .map(|this| match &self.view {
                 SidebarView::ThreadList => this
@@ -3263,38 +3235,64 @@ impl Render for Sidebar {
                     }),
                 SidebarView::Archive(archive_view) => this.child(archive_view.clone()),
             })
-            .child(
-                h_flex()
-                    .p_1()
-                    .gap_1()
-                    .justify_between()
-                    .border_t_1()
-                    .border_color(cx.theme().colors().border)
-                    .child(self.render_sidebar_toggle_button(cx))
-                    .child(
-                        h_flex()
-                            .gap_1()
-                            .child(
-                                IconButton::new("archive", IconName::Archive)
-                                    .icon_size(IconSize::Small)
-                                    .toggle_state(matches!(self.view, SidebarView::Archive(..)))
-                                    .tooltip(move |_, cx| {
-                                        Tooltip::for_action(
-                                            "Toggle Archived Threads",
-                                            &ToggleArchive,
-                                            cx,
-                                        )
-                                    })
-                                    .on_click(cx.listener(|this, _, window, cx| {
-                                        this.toggle_archive(&ToggleArchive, window, cx);
-                                    })),
-                            )
-                            .child(self.render_recent_projects_button(cx)),
-                    ),
-            )
+            .child(self.render_sidebar_bottom_bar(cx))
     }
 }
 
+fn all_thread_infos_for_workspace(
+    workspace: &Entity<Workspace>,
+    cx: &App,
+) -> impl Iterator<Item = ActiveThreadInfo> {
+    let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
+        return None.into_iter().flatten();
+    };
+    let agent_panel = agent_panel.read(cx);
+
+    let threads = agent_panel
+        .parent_threads(cx)
+        .into_iter()
+        .map(|thread_view| {
+            let thread_view_ref = thread_view.read(cx);
+            let thread = thread_view_ref.thread.read(cx);
+
+            let icon = thread_view_ref.agent_icon;
+            let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone();
+            let title = thread
+                .title()
+                .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
+            let is_native = thread_view_ref.as_native_thread(cx).is_some();
+            let is_title_generating = is_native && thread.has_provisional_title();
+            let session_id = thread.session_id().clone();
+            let is_background = agent_panel.is_background_thread(&session_id);
+
+            let status = if thread.is_waiting_for_confirmation() {
+                AgentThreadStatus::WaitingForConfirmation
+            } else if thread.had_error() {
+                AgentThreadStatus::Error
+            } else {
+                match thread.status() {
+                    ThreadStatus::Generating => AgentThreadStatus::Running,
+                    ThreadStatus::Idle => AgentThreadStatus::Completed,
+                }
+            };
+
+            let diff_stats = thread.action_log().read(cx).diff_stats(cx);
+
+            ActiveThreadInfo {
+                session_id,
+                title,
+                status,
+                icon,
+                icon_from_external_svg,
+                is_background,
+                is_title_generating,
+                diff_stats,
+            }
+        });
+
+    Some(threads).into_iter().flatten()
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -5883,10 +5881,9 @@ mod tests {
         assert_eq!(
             visible_entries_as_strings(&sidebar, cx),
             vec![
-                "v [wt-feature-a]",
-                "  Thread A",
-                "v [wt-feature-b]",
-                "  Thread B",
+                "v [project]",
+                "  Thread A {wt-feature-a}",
+                "  Thread B {wt-feature-b}",
             ]
         );
 

crates/theme/src/fallback_themes.rs πŸ”—

@@ -314,70 +314,68 @@ pub(crate) fn zed_default_dark() -> Theme {
                 warning_border: yellow,
             },
             player,
-            syntax: Arc::new(SyntaxTheme {
-                highlights: vec![
-                    ("attribute".into(), purple.into()),
-                    ("boolean".into(), orange.into()),
-                    ("comment".into(), gray.into()),
-                    ("comment.doc".into(), gray.into()),
-                    ("constant".into(), yellow.into()),
-                    ("constructor".into(), blue.into()),
-                    ("embedded".into(), HighlightStyle::default()),
-                    (
-                        "emphasis".into(),
-                        HighlightStyle {
-                            font_style: Some(FontStyle::Italic),
-                            ..HighlightStyle::default()
-                        },
-                    ),
-                    (
-                        "emphasis.strong".into(),
-                        HighlightStyle {
-                            font_weight: Some(FontWeight::BOLD),
-                            ..HighlightStyle::default()
-                        },
-                    ),
-                    ("enum".into(), teal.into()),
-                    ("function".into(), blue.into()),
-                    ("function.method".into(), blue.into()),
-                    ("function.definition".into(), blue.into()),
-                    ("hint".into(), blue.into()),
-                    ("keyword".into(), purple.into()),
-                    ("label".into(), HighlightStyle::default()),
-                    ("link_text".into(), blue.into()),
-                    (
-                        "link_uri".into(),
-                        HighlightStyle {
-                            color: Some(teal),
-                            font_style: Some(FontStyle::Italic),
-                            ..HighlightStyle::default()
-                        },
-                    ),
-                    ("number".into(), orange.into()),
-                    ("operator".into(), HighlightStyle::default()),
-                    ("predictive".into(), HighlightStyle::default()),
-                    ("preproc".into(), HighlightStyle::default()),
-                    ("primary".into(), HighlightStyle::default()),
-                    ("property".into(), red.into()),
-                    ("punctuation".into(), HighlightStyle::default()),
-                    ("punctuation.bracket".into(), HighlightStyle::default()),
-                    ("punctuation.delimiter".into(), HighlightStyle::default()),
-                    ("punctuation.list_marker".into(), HighlightStyle::default()),
-                    ("punctuation.special".into(), HighlightStyle::default()),
-                    ("string".into(), green.into()),
-                    ("string.escape".into(), HighlightStyle::default()),
-                    ("string.regex".into(), red.into()),
-                    ("string.special".into(), HighlightStyle::default()),
-                    ("string.special.symbol".into(), HighlightStyle::default()),
-                    ("tag".into(), HighlightStyle::default()),
-                    ("text.literal".into(), HighlightStyle::default()),
-                    ("title".into(), HighlightStyle::default()),
-                    ("type".into(), teal.into()),
-                    ("variable".into(), HighlightStyle::default()),
-                    ("variable.special".into(), red.into()),
-                    ("variant".into(), HighlightStyle::default()),
-                ],
-            }),
+            syntax: Arc::new(SyntaxTheme::new(vec![
+                ("attribute".into(), purple.into()),
+                ("boolean".into(), orange.into()),
+                ("comment".into(), gray.into()),
+                ("comment.doc".into(), gray.into()),
+                ("constant".into(), yellow.into()),
+                ("constructor".into(), blue.into()),
+                ("embedded".into(), HighlightStyle::default()),
+                (
+                    "emphasis".into(),
+                    HighlightStyle {
+                        font_style: Some(FontStyle::Italic),
+                        ..HighlightStyle::default()
+                    },
+                ),
+                (
+                    "emphasis.strong".into(),
+                    HighlightStyle {
+                        font_weight: Some(FontWeight::BOLD),
+                        ..HighlightStyle::default()
+                    },
+                ),
+                ("enum".into(), teal.into()),
+                ("function".into(), blue.into()),
+                ("function.method".into(), blue.into()),
+                ("function.definition".into(), blue.into()),
+                ("hint".into(), blue.into()),
+                ("keyword".into(), purple.into()),
+                ("label".into(), HighlightStyle::default()),
+                ("link_text".into(), blue.into()),
+                (
+                    "link_uri".into(),
+                    HighlightStyle {
+                        color: Some(teal),
+                        font_style: Some(FontStyle::Italic),
+                        ..HighlightStyle::default()
+                    },
+                ),
+                ("number".into(), orange.into()),
+                ("operator".into(), HighlightStyle::default()),
+                ("predictive".into(), HighlightStyle::default()),
+                ("preproc".into(), HighlightStyle::default()),
+                ("primary".into(), HighlightStyle::default()),
+                ("property".into(), red.into()),
+                ("punctuation".into(), HighlightStyle::default()),
+                ("punctuation.bracket".into(), HighlightStyle::default()),
+                ("punctuation.delimiter".into(), HighlightStyle::default()),
+                ("punctuation.list_marker".into(), HighlightStyle::default()),
+                ("punctuation.special".into(), HighlightStyle::default()),
+                ("string".into(), green.into()),
+                ("string.escape".into(), HighlightStyle::default()),
+                ("string.regex".into(), red.into()),
+                ("string.special".into(), HighlightStyle::default()),
+                ("string.special.symbol".into(), HighlightStyle::default()),
+                ("tag".into(), HighlightStyle::default()),
+                ("text.literal".into(), HighlightStyle::default()),
+                ("title".into(), HighlightStyle::default()),
+                ("type".into(), teal.into()),
+                ("variable".into(), HighlightStyle::default()),
+                ("variable.special".into(), red.into()),
+                ("variant".into(), HighlightStyle::default()),
+            ])),
         },
     }
 }

crates/theme/src/styles/syntax.rs πŸ”—

@@ -1,15 +1,38 @@
 #![allow(missing_docs)]
 
-use std::sync::Arc;
+use std::{
+    collections::{BTreeMap, btree_map::Entry},
+    sync::Arc,
+};
 
-use gpui::{HighlightStyle, Hsla};
+use gpui::HighlightStyle;
+#[cfg(any(test, feature = "test-support"))]
+use gpui::Hsla;
 
 #[derive(Debug, PartialEq, Eq, Clone, Default)]
 pub struct SyntaxTheme {
-    pub highlights: Vec<(String, HighlightStyle)>,
+    pub(self) highlights: Vec<HighlightStyle>,
+    pub(self) capture_name_map: BTreeMap<String, usize>,
 }
 
 impl SyntaxTheme {
+    pub fn new(highlights: impl IntoIterator<Item = (String, HighlightStyle)>) -> Self {
+        let (capture_names, highlights) = highlights.into_iter().unzip();
+
+        Self {
+            capture_name_map: Self::create_capture_name_map(capture_names),
+            highlights,
+        }
+    }
+
+    fn create_capture_name_map(highlights: Vec<String>) -> BTreeMap<String, usize> {
+        highlights
+            .into_iter()
+            .enumerate()
+            .map(|(i, key)| (key, i))
+            .collect()
+    }
+
     #[cfg(any(test, feature = "test-support"))]
     pub fn new_test(colors: impl IntoIterator<Item = (&'static str, Hsla)>) -> Self {
         Self::new_test_styles(colors.into_iter().map(|(key, color)| {
@@ -27,34 +50,46 @@ impl SyntaxTheme {
     pub fn new_test_styles(
         colors: impl IntoIterator<Item = (&'static str, HighlightStyle)>,
     ) -> Self {
-        Self {
-            highlights: colors
+        Self::new(
+            colors
                 .into_iter()
-                .map(|(key, style)| (key.to_owned(), style))
-                .collect(),
-        }
+                .map(|(key, style)| (key.to_owned(), style)),
+        )
     }
 
-    pub fn get(&self, name: &str) -> HighlightStyle {
-        self.highlights
-            .iter()
-            .find_map(|entry| if entry.0 == name { Some(entry.1) } else { None })
-            .unwrap_or_default()
+    pub fn get(&self, highlight_index: impl Into<usize>) -> Option<&HighlightStyle> {
+        self.highlights.get(highlight_index.into())
     }
 
-    pub fn get_opt(&self, name: &str) -> Option<HighlightStyle> {
-        self.highlights
-            .iter()
-            .find_map(|entry| if entry.0 == name { Some(entry.1) } else { None })
+    pub fn style_for_name(&self, name: &str) -> Option<HighlightStyle> {
+        self.capture_name_map
+            .get(name)
+            .map(|highlight_idx| self.highlights[*highlight_idx])
     }
 
-    pub fn color(&self, name: &str) -> Hsla {
-        self.get(name).color.unwrap_or_default()
+    pub fn get_capture_name(&self, idx: impl Into<usize>) -> Option<&str> {
+        let idx = idx.into();
+        self.capture_name_map
+            .iter()
+            .find(|(_, value)| **value == idx)
+            .map(|(key, _)| key.as_ref())
     }
 
-    pub fn highlight_id(&self, name: &str) -> Option<u32> {
-        let ix = self.highlights.iter().position(|entry| entry.0 == name)?;
-        Some(ix as u32)
+    pub fn highlight_id(&self, capture_name: &str) -> Option<u32> {
+        self.capture_name_map
+            .range::<str, _>((
+                capture_name.split(".").next().map_or(
+                    std::ops::Bound::Included(capture_name),
+                    std::ops::Bound::Included,
+                ),
+                std::ops::Bound::Included(capture_name),
+            ))
+            .rfind(|(prefix, _)| {
+                capture_name
+                    .strip_prefix(*prefix)
+                    .is_some_and(|remainder| remainder.is_empty() || remainder.starts_with('.'))
+            })
+            .map(|(_, index)| *index as u32)
     }
 
     /// Returns a new [`Arc<SyntaxTheme>`] with the given syntax styles merged in.
@@ -63,33 +98,36 @@ impl SyntaxTheme {
             return base;
         }
 
-        let mut merged_highlights = base.highlights.clone();
+        let mut base = Arc::try_unwrap(base).unwrap_or_else(|base| (*base).clone());
 
         for (name, highlight) in user_syntax_styles {
-            if let Some((_, existing_highlight)) = merged_highlights
-                .iter_mut()
-                .find(|(existing_name, _)| existing_name == &name)
-            {
-                existing_highlight.color = highlight.color.or(existing_highlight.color);
-                existing_highlight.font_weight =
-                    highlight.font_weight.or(existing_highlight.font_weight);
-                existing_highlight.font_style =
-                    highlight.font_style.or(existing_highlight.font_style);
-                existing_highlight.background_color = highlight
-                    .background_color
-                    .or(existing_highlight.background_color);
-                existing_highlight.underline = highlight.underline.or(existing_highlight.underline);
-                existing_highlight.strikethrough =
-                    highlight.strikethrough.or(existing_highlight.strikethrough);
-                existing_highlight.fade_out = highlight.fade_out.or(existing_highlight.fade_out);
-            } else {
-                merged_highlights.push((name, highlight));
+            match base.capture_name_map.entry(name) {
+                Entry::Occupied(entry) => {
+                    if let Some(existing_highlight) = base.highlights.get_mut(*entry.get()) {
+                        existing_highlight.color = highlight.color.or(existing_highlight.color);
+                        existing_highlight.font_weight =
+                            highlight.font_weight.or(existing_highlight.font_weight);
+                        existing_highlight.font_style =
+                            highlight.font_style.or(existing_highlight.font_style);
+                        existing_highlight.background_color = highlight
+                            .background_color
+                            .or(existing_highlight.background_color);
+                        existing_highlight.underline =
+                            highlight.underline.or(existing_highlight.underline);
+                        existing_highlight.strikethrough =
+                            highlight.strikethrough.or(existing_highlight.strikethrough);
+                        existing_highlight.fade_out =
+                            highlight.fade_out.or(existing_highlight.fade_out);
+                    }
+                }
+                Entry::Vacant(vacant) => {
+                    vacant.insert(base.highlights.len());
+                    base.highlights.push(highlight);
+                }
             }
         }
 
-        Arc::new(Self {
-            highlights: merged_highlights,
-        })
+        Arc::new(base)
     }
 }
 

crates/theme/src/theme.rs πŸ”—

@@ -258,30 +258,25 @@ impl ThemeFamily {
         };
         refined_accent_colors.merge(&theme.style.accents);
 
-        let syntax_highlights = theme
-            .style
-            .syntax
-            .iter()
-            .map(|(syntax_token, highlight)| {
-                (
-                    syntax_token.clone(),
-                    HighlightStyle {
-                        color: highlight
-                            .color
-                            .as_ref()
-                            .and_then(|color| try_parse_color(color).ok()),
-                        background_color: highlight
-                            .background_color
-                            .as_ref()
-                            .and_then(|color| try_parse_color(color).ok()),
-                        font_style: highlight.font_style.map(|s| s.into_gpui()),
-                        font_weight: highlight.font_weight.map(|w| w.into_gpui()),
-                        ..Default::default()
-                    },
-                )
-            })
-            .collect::<Vec<_>>();
-        let syntax_theme = SyntaxTheme::merge(Arc::new(SyntaxTheme::default()), syntax_highlights);
+        let syntax_highlights = theme.style.syntax.iter().map(|(syntax_token, highlight)| {
+            (
+                syntax_token.clone(),
+                HighlightStyle {
+                    color: highlight
+                        .color
+                        .as_ref()
+                        .and_then(|color| try_parse_color(color).ok()),
+                    background_color: highlight
+                        .background_color
+                        .as_ref()
+                        .and_then(|color| try_parse_color(color).ok()),
+                    font_style: highlight.font_style.map(|s| s.into_gpui()),
+                    font_weight: highlight.font_weight.map(|w| w.into_gpui()),
+                    ..Default::default()
+                },
+            )
+        });
+        let syntax_theme = Arc::new(SyntaxTheme::new(syntax_highlights));
 
         let window_background_appearance = theme
             .style
@@ -381,12 +376,6 @@ impl Theme {
         &self.styles.status
     }
 
-    /// Returns the color for the syntax node with the given name.
-    #[inline(always)]
-    pub fn syntax_color(&self, name: &str) -> Hsla {
-        self.syntax().color(name)
-    }
-
     /// Returns the [`Appearance`] for the theme.
     #[inline(always)]
     pub fn appearance(&self) -> Appearance {

crates/title_bar/src/title_bar.rs πŸ”—

@@ -81,7 +81,8 @@ pub fn init(cx: &mut App) {
         let Some(window) = window else {
             return;
         };
-        let item = cx.new(|cx| TitleBar::new("title-bar", workspace, window, cx));
+        let multi_workspace = workspace.multi_workspace().cloned();
+        let item = cx.new(|cx| TitleBar::new("title-bar", workspace, multi_workspace, window, cx));
         workspace.set_titlebar_item(item.into(), window, cx);
 
         workspace.register_action(|workspace, _: &SimulateUpdateAvailable, _window, cx| {
@@ -161,7 +162,18 @@ pub struct TitleBar {
 
 impl Render for TitleBar {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        self.sync_multi_workspace(window, cx);
+        if self.multi_workspace.is_none() {
+            if let Some(mw) = self
+                .workspace
+                .upgrade()
+                .and_then(|ws| ws.read(cx).multi_workspace().cloned())
+            {
+                self.multi_workspace = Some(mw.clone());
+                self.platform_titlebar.update(cx, |titlebar, _cx| {
+                    titlebar.set_multi_workspace(mw);
+                });
+            }
+        }
 
         let title_bar_settings = *TitleBarSettings::get_global(cx);
         let button_layout = title_bar_settings.button_layout;
@@ -308,6 +320,7 @@ impl TitleBar {
     pub fn new(
         id: impl Into<ElementId>,
         workspace: &Workspace,
+        multi_workspace: Option<WeakEntity<MultiWorkspace>>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
@@ -385,52 +398,19 @@ impl TitleBar {
         });
 
         let update_version = cx.new(|cx| UpdateVersion::new(cx));
-        let platform_titlebar = cx.new(|cx| PlatformTitleBar::new(id, cx));
-
-        // Set up observer to sync sidebar state from MultiWorkspace to PlatformTitleBar.
-        {
-            let platform_titlebar = platform_titlebar.clone();
-            let window_handle = window.window_handle();
-            cx.spawn(async move |this: WeakEntity<TitleBar>, cx| {
-                let Some(multi_workspace_handle) = window_handle.downcast::<MultiWorkspace>()
-                else {
-                    return;
-                };
-
-                let _ = cx.update(|cx| {
-                    let Ok(multi_workspace) = multi_workspace_handle.entity(cx) else {
-                        return;
-                    };
-
-                    let is_open = multi_workspace.read(cx).sidebar_open();
-                    platform_titlebar.update(cx, |titlebar, cx| {
-                        titlebar.set_workspace_sidebar_open(is_open, cx);
-                    });
-
-                    let platform_titlebar = platform_titlebar.clone();
-                    let subscription = cx.observe(&multi_workspace, move |mw, cx| {
-                        let is_open = mw.read(cx).sidebar_open();
-                        platform_titlebar.update(cx, |titlebar, cx| {
-                            titlebar.set_workspace_sidebar_open(is_open, cx);
-                        });
-                    });
-
-                    if let Some(this) = this.upgrade() {
-                        this.update(cx, |this, _| {
-                            this._subscriptions.push(subscription);
-                            this.multi_workspace = Some(multi_workspace.downgrade());
-                        });
-                    }
-                });
-            })
-            .detach();
-        }
+        let platform_titlebar = cx.new(|cx| {
+            let mut titlebar = PlatformTitleBar::new(id, cx);
+            if let Some(mw) = multi_workspace.clone() {
+                titlebar = titlebar.with_multi_workspace(mw);
+            }
+            titlebar
+        });
 
         let mut this = Self {
             platform_titlebar,
             application_menu,
             workspace: workspace.weak_handle(),
-            multi_workspace: None,
+            multi_workspace,
             project,
             user_store,
             client,
@@ -446,46 +426,6 @@ impl TitleBar {
         this
     }
 
-    /// Used to update the title bar state in case the workspace has
-    /// been moved to a new window through the threads sidebar.
-    fn sync_multi_workspace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        let current = window
-            .root::<MultiWorkspace>()
-            .flatten()
-            .map(|mw| mw.entity_id());
-
-        let tracked = self
-            .multi_workspace
-            .as_ref()
-            .and_then(|weak| weak.upgrade())
-            .map(|mw| mw.entity_id());
-
-        if current == tracked {
-            return;
-        }
-
-        let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() else {
-            self.multi_workspace = None;
-            return;
-        };
-
-        let is_open = multi_workspace.read(cx).sidebar_open();
-        self.platform_titlebar.update(cx, |titlebar, cx| {
-            titlebar.set_workspace_sidebar_open(is_open, cx);
-        });
-
-        let platform_titlebar = self.platform_titlebar.clone();
-        let subscription = cx.observe(&multi_workspace, move |_this, mw, cx| {
-            let is_open = mw.read(cx).sidebar_open();
-            platform_titlebar.update(cx, |titlebar, cx| {
-                titlebar.set_workspace_sidebar_open(is_open, cx);
-            });
-        });
-
-        self.multi_workspace = Some(multi_workspace.downgrade());
-        self._subscriptions.push(subscription);
-    }
-
     fn worktree_count(&self, cx: &App) -> usize {
         self.project.read(cx).visible_worktrees(cx).count()
     }
@@ -777,7 +717,13 @@ impl TitleBar {
             "Open Recent Project".to_string()
         };
 
-        let is_sidebar_open = self.platform_titlebar.read(cx).is_workspace_sidebar_open();
+        let is_sidebar_open = self
+            .multi_workspace
+            .as_ref()
+            .and_then(|mw| mw.upgrade())
+            .map(|mw| mw.read(cx).sidebar_open())
+            .unwrap_or(false)
+            && PlatformTitleBar::is_multi_workspace_enabled(cx);
 
         let is_threads_list_view_active = self
             .multi_workspace

crates/ui/src/components/ai.rs πŸ”—

@@ -1,5 +1,7 @@
+mod ai_setting_item;
 mod configured_api_card;
 mod thread_item;
 
+pub use ai_setting_item::*;
 pub use configured_api_card::*;
 pub use thread_item::*;

crates/ui/src/components/ai/ai_setting_item.rs πŸ”—

@@ -0,0 +1,406 @@
+use crate::{IconDecoration, IconDecorationKind, Tooltip, prelude::*};
+use gpui::{Animation, AnimationExt, SharedString, pulsating_between};
+use std::time::Duration;
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum AiSettingItemStatus {
+    #[default]
+    Stopped,
+    Starting,
+    Running,
+    Error,
+    AuthRequired,
+    Authenticating,
+}
+
+impl AiSettingItemStatus {
+    fn tooltip_text(&self) -> &'static str {
+        match self {
+            Self::Stopped => "Server is stopped.",
+            Self::Starting => "Server is starting.",
+            Self::Running => "Server is active.",
+            Self::Error => "Server has an error.",
+            Self::AuthRequired => "Authentication required.",
+            Self::Authenticating => "Waiting for authorization…",
+        }
+    }
+
+    fn indicator_color(&self) -> Option<Color> {
+        match self {
+            Self::Stopped => None,
+            Self::Starting | Self::Authenticating => Some(Color::Muted),
+            Self::Running => Some(Color::Success),
+            Self::Error => Some(Color::Error),
+            Self::AuthRequired => Some(Color::Warning),
+        }
+    }
+
+    fn is_animated(&self) -> bool {
+        matches!(self, Self::Starting | Self::Authenticating)
+    }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum AiSettingItemSource {
+    Extension,
+    Custom,
+    Registry,
+}
+
+impl AiSettingItemSource {
+    fn icon_name(&self) -> IconName {
+        match self {
+            Self::Extension => IconName::ZedSrcExtension,
+            Self::Custom => IconName::ZedSrcCustom,
+            Self::Registry => IconName::AcpRegistry,
+        }
+    }
+
+    fn tooltip_text(&self, label: &str) -> String {
+        match self {
+            Self::Extension => format!("{label} was installed from an extension."),
+            Self::Registry => format!("{label} was installed from the ACP registry."),
+            Self::Custom => format!("{label} was configured manually."),
+        }
+    }
+}
+
+/// A reusable setting item row for AI-related configuration lists.
+#[derive(IntoElement, RegisterComponent)]
+pub struct AiSettingItem {
+    id: ElementId,
+    status: AiSettingItemStatus,
+    source: AiSettingItemSource,
+    icon: Option<AnyElement>,
+    label: SharedString,
+    detail_label: Option<SharedString>,
+    actions: Vec<AnyElement>,
+    details: Option<AnyElement>,
+}
+
+impl AiSettingItem {
+    pub fn new(
+        id: impl Into<ElementId>,
+        label: impl Into<SharedString>,
+        status: AiSettingItemStatus,
+        source: AiSettingItemSource,
+    ) -> Self {
+        Self {
+            id: id.into(),
+            status,
+            source,
+            icon: None,
+            label: label.into(),
+            detail_label: None,
+            actions: Vec::new(),
+            details: None,
+        }
+    }
+
+    pub fn icon(mut self, element: impl IntoElement) -> Self {
+        self.icon = Some(element.into_any_element());
+        self
+    }
+
+    pub fn detail_label(mut self, detail: impl Into<SharedString>) -> Self {
+        self.detail_label = Some(detail.into());
+        self
+    }
+
+    pub fn action(mut self, element: impl IntoElement) -> Self {
+        self.actions.push(element.into_any_element());
+        self
+    }
+
+    pub fn details(mut self, element: impl IntoElement) -> Self {
+        self.details = Some(element.into_any_element());
+        self
+    }
+}
+
+impl RenderOnce for AiSettingItem {
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let Self {
+            id,
+            status,
+            source,
+            icon,
+            label,
+            detail_label,
+            actions,
+            details,
+        } = self;
+
+        let source_id = format!("source-{}", id);
+        let icon_id = format!("icon-{}", id);
+        let status_tooltip = status.tooltip_text();
+        let source_tooltip = source.tooltip_text(&label);
+
+        let icon_element = icon.unwrap_or_else(|| {
+            let letter = label.chars().next().unwrap_or('?').to_ascii_uppercase();
+
+            h_flex()
+                .size_5()
+                .flex_none()
+                .justify_center()
+                .rounded_sm()
+                .border_1()
+                .border_color(cx.theme().colors().border_variant)
+                .bg(cx.theme().colors().element_active.opacity(0.2))
+                .child(
+                    Label::new(SharedString::from(letter.to_string()))
+                        .size(LabelSize::Small)
+                        .color(Color::Muted)
+                        .buffer_font(cx),
+                )
+                .into_any_element()
+        });
+
+        let icon_child = if status.is_animated() {
+            div()
+                .child(icon_element)
+                .with_animation(
+                    format!("icon-pulse-{}", id),
+                    Animation::new(Duration::from_secs(2))
+                        .repeat()
+                        .with_easing(pulsating_between(0.4, 0.8)),
+                    |element, delta| element.opacity(delta),
+                )
+                .into_any_element()
+        } else {
+            icon_element.into_any_element()
+        };
+
+        let icon_container = div()
+            .id(icon_id)
+            .relative()
+            .flex_none()
+            .tooltip(Tooltip::text(status_tooltip))
+            .child(icon_child)
+            .when_some(status.indicator_color(), |this, color| {
+                this.child(
+                    IconDecoration::new(
+                        IconDecorationKind::Dot,
+                        cx.theme().colors().panel_background,
+                        cx,
+                    )
+                    .size(px(12.))
+                    .color(color.color(cx))
+                    .position(gpui::Point {
+                        x: px(-3.),
+                        y: px(-3.),
+                    }),
+                )
+            });
+
+        v_flex()
+            .id(id)
+            .min_w_0()
+            .child(
+                h_flex()
+                    .min_w_0()
+                    .w_full()
+                    .gap_1p5()
+                    .justify_between()
+                    .child(
+                        h_flex()
+                            .flex_1()
+                            .min_w_0()
+                            .gap_1p5()
+                            .child(icon_container)
+                            .child(Label::new(label).flex_shrink_0().truncate())
+                            .child(
+                                div()
+                                    .id(source_id)
+                                    .min_w_0()
+                                    .flex_none()
+                                    .tooltip(Tooltip::text(source_tooltip))
+                                    .child(
+                                        Icon::new(source.icon_name())
+                                            .size(IconSize::Small)
+                                            .color(Color::Muted),
+                                    ),
+                            )
+                            .when_some(detail_label, |this, detail| {
+                                this.child(
+                                    Label::new(detail)
+                                        .color(Color::Muted)
+                                        .size(LabelSize::Small),
+                                )
+                            }),
+                    )
+                    .when(!actions.is_empty(), |this| {
+                        this.child(h_flex().gap_0p5().flex_none().children(actions))
+                    }),
+            )
+            .children(details)
+    }
+}
+
+impl Component for AiSettingItem {
+    fn scope() -> ComponentScope {
+        ComponentScope::Agent
+    }
+
+    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
+        let container = || {
+            v_flex()
+                .w_80()
+                .p_2()
+                .gap_2()
+                .border_1()
+                .border_color(cx.theme().colors().border_variant)
+                .bg(cx.theme().colors().panel_background)
+        };
+
+        let details_row = |icon_name: IconName, icon_color: Color, message: &str| {
+            h_flex()
+                .py_1()
+                .min_w_0()
+                .w_full()
+                .gap_2()
+                .justify_between()
+                .child(
+                    h_flex()
+                        .pr_4()
+                        .min_w_0()
+                        .w_full()
+                        .gap_2()
+                        .child(
+                            Icon::new(icon_name)
+                                .size(IconSize::XSmall)
+                                .color(icon_color),
+                        )
+                        .child(
+                            div().min_w_0().flex_1().child(
+                                Label::new(SharedString::from(message.to_string()))
+                                    .color(Color::Muted)
+                                    .size(LabelSize::Small),
+                            ),
+                        ),
+                )
+        };
+
+        let examples = vec![
+            single_example(
+                "MCP server with letter avatar (running)",
+                container()
+                    .child(
+                        AiSettingItem::new(
+                            "ext-mcp",
+                            "Postgres",
+                            AiSettingItemStatus::Running,
+                            AiSettingItemSource::Extension,
+                        )
+                        .detail_label("3 tools")
+                        .action(
+                            IconButton::new("menu", IconName::Settings)
+                                .icon_size(IconSize::Small)
+                                .icon_color(Color::Muted),
+                        )
+                        .action(
+                            IconButton::new("toggle", IconName::Check)
+                                .icon_size(IconSize::Small)
+                                .icon_color(Color::Muted),
+                        ),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "MCP server (stopped)",
+                container()
+                    .child(AiSettingItem::new(
+                        "custom-mcp",
+                        "my-local-server",
+                        AiSettingItemStatus::Stopped,
+                        AiSettingItemSource::Custom,
+                    ))
+                    .into_any_element(),
+            ),
+            single_example(
+                "MCP server (starting, animated)",
+                container()
+                    .child(AiSettingItem::new(
+                        "starting-mcp",
+                        "Context7",
+                        AiSettingItemStatus::Starting,
+                        AiSettingItemSource::Extension,
+                    ))
+                    .into_any_element(),
+            ),
+            single_example(
+                "Agent with icon (running)",
+                container()
+                    .child(
+                        AiSettingItem::new(
+                            "ext-agent",
+                            "Claude Agent",
+                            AiSettingItemStatus::Running,
+                            AiSettingItemSource::Extension,
+                        )
+                        .icon(
+                            Icon::new(IconName::AiClaude)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        )
+                        .action(
+                            IconButton::new("restart", IconName::RotateCw)
+                                .icon_size(IconSize::Small)
+                                .icon_color(Color::Muted),
+                        )
+                        .action(
+                            IconButton::new("delete", IconName::Trash)
+                                .icon_size(IconSize::Small)
+                                .icon_color(Color::Muted),
+                        ),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Registry agent (starting, animated)",
+                container()
+                    .child(
+                        AiSettingItem::new(
+                            "reg-agent",
+                            "Devin Agent",
+                            AiSettingItemStatus::Starting,
+                            AiSettingItemSource::Registry,
+                        )
+                        .icon(
+                            Icon::new(IconName::ZedAssistant)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        ),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Error with details",
+                container()
+                    .child(
+                        AiSettingItem::new(
+                            "error-mcp",
+                            "Amplitude",
+                            AiSettingItemStatus::Error,
+                            AiSettingItemSource::Extension,
+                        )
+                        .details(
+                            details_row(
+                                IconName::XCircle,
+                                Color::Error,
+                                "Failed to connect: connection refused",
+                            )
+                            .child(
+                                Button::new("logout", "Log Out")
+                                    .style(ButtonStyle::Outlined)
+                                    .label_size(LabelSize::Small),
+                            ),
+                        ),
+                    )
+                    .into_any_element(),
+            ),
+        ];
+
+        Some(example_group(examples).vertical().into_any_element())
+    }
+}

crates/ui/src/components/icon/icon_decoration.rs πŸ”—

@@ -63,6 +63,7 @@ pub struct IconDecoration {
     color: Hsla,
     knockout_color: Hsla,
     knockout_hover_color: Hsla,
+    size: Pixels,
     position: Point<Pixels>,
     group_name: Option<SharedString>,
 }
@@ -78,6 +79,7 @@ impl IconDecoration {
             color,
             knockout_color,
             knockout_hover_color: knockout_color,
+            size: ICON_DECORATION_SIZE,
             position,
             group_name: None,
         }
@@ -116,6 +118,12 @@ impl IconDecoration {
         self
     }
 
+    /// Sets the size of the decoration.
+    pub fn size(mut self, size: Pixels) -> Self {
+        self.size = size;
+        self
+    }
+
     /// Sets the name of the group the decoration belongs to
     pub fn group_name(mut self, name: Option<SharedString>) -> Self {
         self.group_name = name;
@@ -125,11 +133,13 @@ impl IconDecoration {
 
 impl RenderOnce for IconDecoration {
     fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+        let size = self.size;
+
         let foreground = svg()
             .absolute()
             .bottom_0()
             .right_0()
-            .size(ICON_DECORATION_SIZE)
+            .size(size)
             .path(self.kind.fg().path())
             .text_color(self.color);
 
@@ -137,7 +147,7 @@ impl RenderOnce for IconDecoration {
             .absolute()
             .bottom_0()
             .right_0()
-            .size(ICON_DECORATION_SIZE)
+            .size(size)
             .path(self.kind.bg().path())
             .text_color(self.knockout_color)
             .map(|this| match self.group_name {
@@ -148,7 +158,7 @@ impl RenderOnce for IconDecoration {
             });
 
         div()
-            .size(ICON_DECORATION_SIZE)
+            .size(size)
             .flex_none()
             .absolute()
             .bottom(self.position.y)

crates/vim/src/helix.rs πŸ”—

@@ -711,38 +711,28 @@ impl Vim {
                 let display_map = editor.display_snapshot(cx);
                 let selections = editor.selections.all_display(&display_map);
 
-                // Store selection info for positioning after edit
-                let selection_info: Vec<_> = selections
-                    .iter()
-                    .map(|selection| {
-                        let range = selection.range();
-                        let start_offset = range.start.to_offset(&display_map, Bias::Left);
-                        let end_offset = range.end.to_offset(&display_map, Bias::Left);
-                        let was_empty = range.is_empty();
-                        let was_reversed = selection.reversed;
-                        (
-                            display_map.buffer_snapshot().anchor_before(start_offset),
-                            end_offset - start_offset,
-                            was_empty,
-                            was_reversed,
-                        )
-                    })
-                    .collect();
-
                 let mut edits = Vec::new();
+                let mut selection_info = Vec::new();
                 for selection in &selections {
                     let mut range = selection.range();
+                    let was_empty = range.is_empty();
+                    let was_reversed = selection.reversed;
 
-                    // For empty selections, extend to replace one character
-                    if range.is_empty() {
+                    if was_empty {
                         range.end = movement::saturating_right(&display_map, range.start);
                     }
 
                     let byte_range = range.start.to_offset(&display_map, Bias::Left)
                         ..range.end.to_offset(&display_map, Bias::Left);
 
+                    let snapshot = display_map.buffer_snapshot();
+                    let grapheme_count = snapshot.grapheme_count_for_range(&byte_range);
+                    let anchor = snapshot.anchor_before(byte_range.start);
+
+                    selection_info.push((anchor, grapheme_count, was_empty, was_reversed));
+
                     if !byte_range.is_empty() {
-                        let replacement_text = text.repeat(byte_range.end - byte_range.start);
+                        let replacement_text = text.repeat(grapheme_count);
                         edits.push((byte_range, replacement_text));
                     }
                 }
@@ -753,14 +743,12 @@ impl Vim {
                 let snapshot = editor.buffer().read(cx).snapshot(cx);
                 let ranges: Vec<_> = selection_info
                     .into_iter()
-                    .map(|(start_anchor, original_len, was_empty, was_reversed)| {
+                    .map(|(start_anchor, grapheme_count, was_empty, was_reversed)| {
                         let start_point = start_anchor.to_point(&snapshot);
                         if was_empty {
-                            // For cursor-only, collapse to start
                             start_point..start_point
                         } else {
-                            // For selections, span the replaced text
-                            let replacement_len = text.len() * original_len;
+                            let replacement_len = text.len() * grapheme_count;
                             let end_offset = start_anchor.to_offset(&snapshot) + replacement_len;
                             let end_point = snapshot.offset_to_point(end_offset);
                             if was_reversed {
@@ -1910,6 +1898,91 @@ mod test {
         );
     }
 
+    #[gpui::test]
+    async fn test_helix_insert_before_after_select_lines(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        cx.set_state(
+            "line one\nline Λ‡two\nline three\nline four",
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("2 x");
+        cx.assert_state(
+            "line one\n«line two\nline three\nˇ»line four",
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("o");
+        cx.assert_state("line one\nline two\nline three\nˇ\nline four", Mode::Insert);
+
+        cx.set_state(
+            "line one\nline Λ‡two\nline three\nline four",
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("2 x");
+        cx.assert_state(
+            "line one\n«line two\nline three\nˇ»line four",
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("shift-o");
+        cx.assert_state("line one\nˇ\nline two\nline three\nline four", Mode::Insert);
+    }
+
+    #[gpui::test]
+    async fn test_helix_insert_before_after_helix_select(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.enable_helix();
+
+        // Test new line in selection direction
+        cx.set_state(
+            "Λ‡line one\nline two\nline three\nline four",
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("v j j");
+        cx.assert_state(
+            "«line one\nline two\nlˇ»ine three\nline four",
+            Mode::HelixSelect,
+        );
+        cx.simulate_keystrokes("o");
+        cx.assert_state("line one\nline two\nline three\nˇ\nline four", Mode::Insert);
+
+        cx.set_state(
+            "line one\nline two\nˇline three\nline four",
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("v k k");
+        cx.assert_state(
+            "Β«Λ‡line one\nline two\nlΒ»ine three\nline four",
+            Mode::HelixSelect,
+        );
+        cx.simulate_keystrokes("shift-o");
+        cx.assert_state("Λ‡\nline one\nline two\nline three\nline four", Mode::Insert);
+
+        // Test new line in opposite selection direction
+        cx.set_state(
+            "Λ‡line one\nline two\nline three\nline four",
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("v j j");
+        cx.assert_state(
+            "«line one\nline two\nlˇ»ine three\nline four",
+            Mode::HelixSelect,
+        );
+        cx.simulate_keystrokes("shift-o");
+        cx.assert_state("Λ‡\nline one\nline two\nline three\nline four", Mode::Insert);
+
+        cx.set_state(
+            "line one\nline two\nˇline three\nline four",
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("v k k");
+        cx.assert_state(
+            "Β«Λ‡line one\nline two\nlΒ»ine three\nline four",
+            Mode::HelixSelect,
+        );
+        cx.simulate_keystrokes("o");
+        cx.assert_state("line one\nline two\nline three\nˇ\nline four", Mode::Insert);
+    }
+
     #[gpui::test]
     async fn test_helix_select_mode_motion(cx: &mut gpui::TestAppContext) {
         let mut cx = VimTestContext::new(cx, true).await;
@@ -2375,4 +2448,22 @@ mod test {
             Mode::Insert,
         );
     }
+
+    #[gpui::test]
+    async fn test_helix_replace_uses_graphemes(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.enable_helix();
+
+        cx.set_state("Β«HΓ€llΓΆΛ‡Β» WΓΆrld", Mode::HelixNormal);
+        cx.simulate_keystrokes("r 1");
+        cx.assert_state("Β«11111Λ‡Β» WΓΆrld", Mode::HelixNormal);
+
+        cx.set_state("Β«e\u{301}Λ‡Β»", Mode::HelixNormal);
+        cx.simulate_keystrokes("r 1");
+        cx.assert_state("Β«1Λ‡Β»", Mode::HelixNormal);
+
+        cx.set_state("Β«πŸ™‚Λ‡Β»", Mode::HelixNormal);
+        cx.simulate_keystrokes("r 1");
+        cx.assert_state("Β«1Λ‡Β»", Mode::HelixNormal);
+    }
 }

crates/vim/src/normal.rs πŸ”—

@@ -731,10 +731,10 @@ impl Vim {
                     .collect::<Vec<_>>();
                 editor.edit_with_autoindent(edits, cx);
                 editor.change_selections(Default::default(), window, cx, |s| {
-                    s.move_cursors_with(&mut |map, cursor, _| {
-                        let previous_line = map.start_of_relative_buffer_row(cursor, -1);
+                    s.move_with(&mut |map, selection| {
+                        let previous_line = map.start_of_relative_buffer_row(selection.start, -1);
                         let insert_point = motion::end_of_line(map, false, previous_line, 1);
-                        (insert_point, SelectionGoal::None)
+                        selection.collapse_to(insert_point, SelectionGoal::None)
                     });
                 });
             });
@@ -750,14 +750,19 @@ impl Vim {
         self.start_recording(cx);
         self.switch_mode(Mode::Insert, false, window, cx);
         self.update_editor(cx, |_, editor, cx| {
-            let text_layout_details = editor.text_layout_details(window, cx);
             editor.transact(window, cx, |editor, window, cx| {
                 let selections = editor.selections.all::<Point>(&editor.display_snapshot(cx));
                 let snapshot = editor.buffer().read(cx).snapshot(cx);
 
                 let selection_end_rows: BTreeSet<u32> = selections
                     .into_iter()
-                    .map(|selection| selection.end.row)
+                    .map(|selection| {
+                        if !selection.is_empty() && selection.end.column == 0 {
+                            selection.end.row.saturating_sub(1)
+                        } else {
+                            selection.end.row
+                        }
+                    })
                     .collect();
                 let edits = selection_end_rows
                     .into_iter()
@@ -772,14 +777,17 @@ impl Vim {
                     })
                     .collect::<Vec<_>>();
                 editor.change_selections(Default::default(), window, cx, |s| {
-                    s.maybe_move_cursors_with(&mut |map, cursor, goal| {
-                        Motion::CurrentLine.move_point(
-                            map,
-                            cursor,
-                            goal,
-                            None,
-                            &text_layout_details,
-                        )
+                    s.move_with(&mut |map, selection| {
+                        let current_line = if !selection.is_empty() && selection.end.column() == 0 {
+                            // If this is an insert after a selection to the end of the line, the
+                            // cursor needs to be bumped back, because it'll be at the start of the
+                            // *next* line.
+                            map.start_of_relative_buffer_row(selection.end, -1)
+                        } else {
+                            selection.end
+                        };
+                        let insert_point = motion::end_of_line(map, false, current_line, 1);
+                        selection.collapse_to(insert_point, SelectionGoal::None)
                     });
                 });
                 editor.edit_with_autoindent(edits, cx);

crates/vim/src/state.rs πŸ”—

@@ -18,6 +18,7 @@ use gpui::{
     EntityId, Global, HighlightStyle, StyledText, Subscription, Task, TextStyle, WeakEntity,
 };
 use language::{Buffer, BufferEvent, BufferId, Chunk, Point};
+
 use multi_buffer::MultiBufferRow;
 use picker::{Picker, PickerDelegate};
 use project::{Project, ProjectItem, ProjectPath};
@@ -1402,8 +1403,8 @@ impl MarksMatchInfo {
         let mut offset = 0;
         for chunk in chunks {
             line.push_str(chunk.text);
-            if let Some(highlight_style) = chunk.syntax_highlight_id
-                && let Some(highlight) = highlight_style.style(cx.theme().syntax())
+            if let Some(highlight_id) = chunk.syntax_highlight_id
+                && let Some(highlight) = cx.theme().syntax().get(highlight_id).cloned()
             {
                 highlights.push((offset..offset + chunk.text.len(), highlight))
             }

crates/vim/src/vim.rs πŸ”—

@@ -1210,7 +1210,7 @@ impl Vim {
             return;
         }
 
-        if !mode.is_visual() && last_mode.is_visual() {
+        if !mode.is_visual() && last_mode.is_visual() && !last_mode.is_helix() {
             self.create_visual_marks(last_mode, window, cx);
         }
 
@@ -1277,7 +1277,7 @@ impl Vim {
                 }
 
                 s.move_with(&mut |map, selection| {
-                    if last_mode.is_visual() && !mode.is_visual() {
+                    if last_mode.is_visual() && !last_mode.is_helix() && !mode.is_visual() {
                         let mut point = selection.head();
                         if !selection.reversed && !selection.is_empty() {
                             point = movement::left(map, selection.head());

crates/vim/src/visual.rs πŸ”—

@@ -788,7 +788,10 @@ impl Vim {
                     {
                         let range = row_range.start.to_offset(&display_map, Bias::Right)
                             ..row_range.end.to_offset(&display_map, Bias::Right);
-                        let text = text.repeat(range.end - range.start);
+                        let grapheme_count = display_map
+                            .buffer_snapshot()
+                            .grapheme_count_for_range(&range);
+                        let text = text.repeat(grapheme_count);
                         edits.push((range, text));
                     }
                 }
@@ -2017,4 +2020,21 @@ mod test {
         // would depend on the key bindings configured, but the actions
         // are now available for use
     }
+
+    #[gpui::test]
+    async fn test_visual_replace_uses_graphemes(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        cx.set_state("Β«HΓ€llΓΆΛ‡Β» WΓΆrld", Mode::Visual);
+        cx.simulate_keystrokes("r 1");
+        cx.assert_state("Λ‡11111 WΓΆrld", Mode::Normal);
+
+        cx.set_state("Β«e\u{301}Λ‡Β»", Mode::Visual);
+        cx.simulate_keystrokes("r 1");
+        cx.assert_state("Λ‡1", Mode::Normal);
+
+        cx.set_state("Β«πŸ™‚Λ‡Β»", Mode::Visual);
+        cx.simulate_keystrokes("r 1");
+        cx.assert_state("Λ‡1", Mode::Normal);
+    }
 }

crates/workspace/Cargo.toml πŸ”—

@@ -27,6 +27,7 @@ test-support = [
 
 [dependencies]
 any_vec.workspace = true
+agent_settings.workspace = true
 anyhow.workspace = true
 async-recursion.workspace = true
 client.workspace = true

crates/workspace/src/active_file_name.rs πŸ”—

@@ -0,0 +1,69 @@
+use gpui::{
+    Context, Empty, EventEmitter, IntoElement, ParentElement, Render, SharedString, Window,
+};
+use settings::Settings;
+use ui::{Button, Tooltip, prelude::*};
+use util::paths::PathStyle;
+
+use crate::{StatusItemView, item::ItemHandle, workspace_settings::StatusBarSettings};
+
+pub struct ActiveFileName {
+    project_path: Option<SharedString>,
+    full_path: Option<SharedString>,
+}
+
+impl ActiveFileName {
+    pub fn new() -> Self {
+        Self {
+            project_path: None,
+            full_path: None,
+        }
+    }
+}
+
+impl Render for ActiveFileName {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        if !StatusBarSettings::get_global(cx).show_active_file {
+            return Empty.into_any_element();
+        }
+
+        let Some(project_path) = self.project_path.clone() else {
+            return Empty.into_any_element();
+        };
+
+        let tooltip_text = self
+            .full_path
+            .clone()
+            .unwrap_or_else(|| project_path.clone());
+
+        div()
+            .child(
+                Button::new("active-file-name-button", project_path)
+                    .label_size(LabelSize::Small)
+                    .tooltip(Tooltip::text(tooltip_text)),
+            )
+            .into_any_element()
+    }
+}
+
+impl EventEmitter<crate::ToolbarItemEvent> for ActiveFileName {}
+
+impl StatusItemView for ActiveFileName {
+    fn set_active_pane_item(
+        &mut self,
+        active_pane_item: Option<&dyn ItemHandle>,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(item) = active_pane_item {
+            self.project_path = item
+                .project_path(cx)
+                .map(|path| path.path.display(PathStyle::local()).into_owned().into());
+            self.full_path = item.tab_tooltip_text(cx);
+        } else {
+            self.project_path = None;
+            self.full_path = None;
+        }
+        cx.notify();
+    }
+}

crates/workspace/src/dock.rs πŸ”—

@@ -69,6 +69,9 @@ pub trait Panel: Focusable + EventEmitter<PanelEvent> + Render + Sized {
     fn enabled(&self, _cx: &App) -> bool {
         true
     }
+    fn is_agent_panel(&self) -> bool {
+        false
+    }
 }
 
 pub trait PanelHandle: Send + Sync {
@@ -95,6 +98,7 @@ pub trait PanelHandle: Send + Sync {
     fn to_any(&self) -> AnyView;
     fn activation_priority(&self, cx: &App) -> u32;
     fn enabled(&self, cx: &App) -> bool;
+    fn is_agent_panel(&self, cx: &App) -> bool;
     fn move_to_next_position(&self, window: &mut Window, cx: &mut App) {
         let current_position = self.position(window, cx);
         let next_position = [
@@ -207,6 +211,10 @@ where
     fn enabled(&self, cx: &App) -> bool {
         self.read(cx).enabled(cx)
     }
+
+    fn is_agent_panel(&self, cx: &App) -> bool {
+        self.read(cx).is_agent_panel()
+    }
 }
 
 impl From<&dyn PanelHandle> for AnyView {
@@ -720,6 +728,12 @@ impl Dock {
         self.panel_entries.len()
     }
 
+    pub fn has_agent_panel(&self, cx: &App) -> bool {
+        self.panel_entries
+            .iter()
+            .any(|entry| entry.panel.is_agent_panel(cx))
+    }
+
     pub fn activate_panel(&mut self, panel_ix: usize, window: &mut Window, cx: &mut Context<Self>) {
         if Some(panel_ix) != self.active_panel_index {
             if let Some(active_panel) = self.active_panel_entry() {
@@ -762,17 +776,9 @@ impl Dock {
         }
     }
 
-    pub fn panel_size(&self, panel: &dyn PanelHandle, window: &Window, cx: &App) -> Option<Pixels> {
-        self.panel_entries
-            .iter()
-            .find(|entry| entry.panel.panel_id() == panel.panel_id())
-            .map(|entry| self.resolved_panel_size(entry, window, cx))
-    }
-
-    pub fn active_panel_size(&self, window: &Window, cx: &App) -> Option<Pixels> {
+    pub fn active_panel_size(&self) -> Option<PanelSizeState> {
         if self.is_open {
-            self.active_panel_entry()
-                .map(|entry| self.resolved_panel_size(entry, window, cx))
+            self.active_panel_entry().map(|entry| entry.size_state)
         } else {
             None
         }
@@ -933,28 +939,6 @@ impl Dock {
         }
     }
 
-    fn resolved_panel_size(&self, entry: &PanelEntry, window: &Window, cx: &App) -> Pixels {
-        if self.position.axis() == Axis::Horizontal
-            && entry.panel.supports_flexible_size(window, cx)
-        {
-            if let Some(workspace) = self.workspace.upgrade() {
-                let workspace = workspace.read(cx);
-                return resolve_panel_size(
-                    entry.size_state,
-                    entry.panel.as_ref(),
-                    self.position,
-                    workspace,
-                    window,
-                    cx,
-                );
-            }
-        }
-        entry
-            .size_state
-            .size
-            .unwrap_or_else(|| entry.panel.default_size(window, cx))
-    }
-
     pub(crate) fn load_persisted_size_state(
         workspace: &Workspace,
         panel_key: &'static str,
@@ -974,41 +958,10 @@ impl Dock {
     }
 }
 
-pub(crate) fn resolve_panel_size(
-    size_state: PanelSizeState,
-    panel: &dyn PanelHandle,
-    position: DockPosition,
-    workspace: &Workspace,
-    window: &Window,
-    cx: &App,
-) -> Pixels {
-    if position.axis() == Axis::Horizontal && panel.supports_flexible_size(window, cx) {
-        let ratio = size_state
-            .flexible_size_ratio
-            .or_else(|| workspace.default_flexible_dock_ratio(position));
-
-        if let Some(ratio) = ratio {
-            return workspace
-                .flexible_dock_size(position, ratio, window, cx)
-                .unwrap_or_else(|| {
-                    size_state
-                        .size
-                        .unwrap_or_else(|| panel.default_size(window, cx))
-                });
-        }
-    }
-
-    size_state
-        .size
-        .unwrap_or_else(|| panel.default_size(window, cx))
-}
-
 impl Render for Dock {
-    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 dispatch_context = Self::dispatch_context();
         if let Some(entry) = self.visible_entry() {
-            let size = self.resolved_panel_size(entry, window, cx);
-
             let position = self.position;
             let create_resize_handle = || {
                 let handle = div()
@@ -1077,8 +1030,10 @@ impl Render for Dock {
                 .border_color(cx.theme().colors().border)
                 .overflow_hidden()
                 .map(|this| match self.position().axis() {
-                    Axis::Horizontal => this.w(size).h_full().flex_row(),
-                    Axis::Vertical => this.h(size).w_full().flex_col(),
+                    // Width and height are always set on the workspace wrapper in
+                    // render_dock, so fill whatever space the wrapper provides.
+                    Axis::Horizontal => this.w_full().h_full().flex_row(),
+                    Axis::Vertical => this.h_full().w_full().flex_col(),
                 })
                 .map(|this| match self.position() {
                     DockPosition::Left => this.border_r_1(),
@@ -1088,8 +1043,8 @@ impl Render for Dock {
                 .child(
                     div()
                         .map(|this| match self.position().axis() {
-                            Axis::Horizontal => this.min_w(size).h_full(),
-                            Axis::Vertical => this.min_h(size).w_full(),
+                            Axis::Horizontal => this.w_full().h_full(),
+                            Axis::Vertical => this.h_full().w_full(),
                         })
                         .child(
                             entry

crates/workspace/src/multi_workspace.rs πŸ”—

@@ -9,6 +9,7 @@ use project::DisableAiSettings;
 #[cfg(any(test, feature = "test-support"))]
 use project::Project;
 use settings::Settings;
+pub use settings::SidebarSide;
 use std::future::Future;
 use std::path::PathBuf;
 use std::sync::Arc;
@@ -16,6 +17,10 @@ use ui::prelude::*;
 use util::ResultExt;
 use zed_actions::agents_sidebar::MoveWorkspaceToNewWindow;
 
+use agent_settings::AgentSettings;
+use settings::SidebarDockPosition;
+use ui::{ContextMenu, right_click_menu};
+
 const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0);
 
 use crate::{
@@ -39,6 +44,47 @@ actions!(
     ]
 );
 
+#[derive(Default)]
+pub struct SidebarRenderState {
+    pub open: bool,
+    pub side: SidebarSide,
+}
+
+pub fn sidebar_side_context_menu(
+    id: impl Into<ElementId>,
+    cx: &App,
+) -> ui::RightClickMenu<ContextMenu> {
+    let current_position = AgentSettings::get_global(cx).sidebar_side;
+    right_click_menu(id).menu(move |window, cx| {
+        let fs = <dyn fs::Fs>::global(cx);
+        ContextMenu::build(window, cx, move |mut menu, _, _cx| {
+            let positions: [(SidebarDockPosition, &str); 3] = [
+                (SidebarDockPosition::Left, "Left"),
+                (SidebarDockPosition::Right, "Right"),
+                (SidebarDockPosition::FollowAgent, "Follow Agent Panel"),
+            ];
+            for (position, label) in positions {
+                let fs = fs.clone();
+                menu = menu.toggleable_entry(
+                    label,
+                    position == current_position,
+                    IconPosition::Start,
+                    None,
+                    move |_window, cx| {
+                        settings::update_settings_file(fs.clone(), cx, move |settings, _cx| {
+                            settings
+                                .agent
+                                .get_or_insert_default()
+                                .set_sidebar_side(position);
+                        });
+                    },
+                );
+            }
+            menu
+        })
+    })
+}
+
 pub enum MultiWorkspaceEvent {
     ActiveWorkspaceChanged,
     WorkspaceAdded(Entity<Workspace>),
@@ -49,6 +95,7 @@ pub trait Sidebar: Focusable + Render + Sized {
     fn width(&self, cx: &App) -> Pixels;
     fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>);
     fn has_notifications(&self, cx: &App) -> bool;
+    fn side(&self, _cx: &App) -> SidebarSide;
 
     fn is_threads_list_view_active(&self) -> bool {
         true
@@ -68,6 +115,8 @@ pub trait SidebarHandle: 'static + Send + Sync {
     fn entity_id(&self) -> EntityId;
 
     fn is_threads_list_view_active(&self, cx: &App) -> bool;
+
+    fn side(&self, cx: &App) -> SidebarSide;
 }
 
 #[derive(Clone)]
@@ -116,6 +165,10 @@ impl<T: Sidebar> SidebarHandle for Entity<T> {
     fn is_threads_list_view_active(&self, cx: &App) -> bool {
         self.read(cx).is_threads_list_view_active()
     }
+
+    fn side(&self, cx: &App) -> SidebarSide {
+        self.read(cx).side(cx)
+    }
 }
 
 pub struct MultiWorkspace {
@@ -132,6 +185,19 @@ pub struct MultiWorkspace {
 impl EventEmitter<MultiWorkspaceEvent> for MultiWorkspace {}
 
 impl MultiWorkspace {
+    pub fn sidebar_side(&self, cx: &App) -> SidebarSide {
+        self.sidebar
+            .as_ref()
+            .map_or(SidebarSide::Left, |s| s.side(cx))
+    }
+
+    pub fn sidebar_render_state(&self, cx: &App) -> SidebarRenderState {
+        SidebarRenderState {
+            open: self.sidebar_open() && self.multi_workspace_enabled(cx),
+            side: self.sidebar_side(cx),
+        }
+    }
+
     pub fn new(workspace: Entity<Workspace>, window: &mut Window, cx: &mut Context<Self>) -> Self {
         let release_subscription = cx.on_release(|this: &mut MultiWorkspace, _cx| {
             if let Some(task) = this._serialize_task.take() {
@@ -149,6 +215,10 @@ impl MultiWorkspace {
                 }
             });
         Self::subscribe_to_workspace(&workspace, cx);
+        let weak_self = cx.weak_entity();
+        workspace.update(cx, |workspace, cx| {
+            workspace.set_multi_workspace(weak_self, cx);
+        });
         Self {
             window_id: window.window_handle().window_id(),
             workspaces: vec![workspace],
@@ -167,20 +237,8 @@ impl MultiWorkspace {
 
     pub fn register_sidebar<T: Sidebar>(&mut self, sidebar: Entity<T>, cx: &mut Context<Self>) {
         self._subscriptions
-            .push(cx.observe(&sidebar, |this, _, cx| {
-                let has_notifications = this.sidebar_has_notifications(cx);
-                let is_open = this.sidebar_open;
-                let show_toggle = this.multi_workspace_enabled(cx);
-                for workspace in &this.workspaces {
-                    workspace.update(cx, |workspace, cx| {
-                        workspace.set_workspace_sidebar_open(
-                            is_open,
-                            has_notifications,
-                            show_toggle,
-                            cx,
-                        );
-                    });
-                }
+            .push(cx.observe(&sidebar, |_this, _, cx| {
+                cx.notify();
             }));
         self.sidebar = Some(Box::new(sidebar));
     }
@@ -266,11 +324,8 @@ impl MultiWorkspace {
     pub fn open_sidebar(&mut self, cx: &mut Context<Self>) {
         self.sidebar_open = true;
         let sidebar_focus_handle = self.sidebar.as_ref().map(|s| s.focus_handle(cx));
-        let has_notifications = self.sidebar_has_notifications(cx);
-        let show_toggle = self.multi_workspace_enabled(cx);
         for workspace in &self.workspaces {
-            workspace.update(cx, |workspace, cx| {
-                workspace.set_workspace_sidebar_open(true, has_notifications, show_toggle, cx);
+            workspace.update(cx, |workspace, _cx| {
                 workspace.set_sidebar_focus_handle(sidebar_focus_handle.clone());
             });
         }
@@ -280,11 +335,8 @@ impl MultiWorkspace {
 
     pub fn close_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         self.sidebar_open = false;
-        let has_notifications = self.sidebar_has_notifications(cx);
-        let show_toggle = self.multi_workspace_enabled(cx);
         for workspace in &self.workspaces {
-            workspace.update(cx, |workspace, cx| {
-                workspace.set_workspace_sidebar_open(false, has_notifications, show_toggle, cx);
+            workspace.update(cx, |workspace, _cx| {
                 workspace.set_sidebar_focus_handle(None);
             });
         }
@@ -381,13 +433,14 @@ impl MultiWorkspace {
         } else {
             if self.sidebar_open {
                 let sidebar_focus_handle = self.sidebar.as_ref().map(|s| s.focus_handle(cx));
-                let has_notifications = self.sidebar_has_notifications(cx);
-                let show_toggle = self.multi_workspace_enabled(cx);
-                workspace.update(cx, |workspace, cx| {
-                    workspace.set_workspace_sidebar_open(true, has_notifications, show_toggle, cx);
+                workspace.update(cx, |workspace, _cx| {
                     workspace.set_sidebar_focus_handle(sidebar_focus_handle);
                 });
             }
+            let weak_self = cx.weak_entity();
+            workspace.update(cx, |workspace, cx| {
+                workspace.set_multi_workspace(weak_self, cx);
+            });
             Self::subscribe_to_workspace(&workspace, cx);
             self.workspaces.push(workspace.clone());
             cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace));
@@ -767,6 +820,8 @@ impl MultiWorkspace {
 impl Render for MultiWorkspace {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let multi_workspace_enabled = self.multi_workspace_enabled(cx);
+        let sidebar_side = self.sidebar_side(cx);
+        let sidebar_on_right = sidebar_side == SidebarSide::Right;
 
         let sidebar: Option<AnyElement> = if multi_workspace_enabled && self.sidebar_open() {
             self.sidebar.as_ref().map(|sidebar_handle| {
@@ -777,7 +832,12 @@ impl Render for MultiWorkspace {
                     div()
                         .id("sidebar-resize-handle")
                         .absolute()
-                        .right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
+                        .when(!sidebar_on_right, |el| {
+                            el.right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
+                        })
+                        .when(sidebar_on_right, |el| {
+                            el.left(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
+                        })
                         .top(px(0.))
                         .h_full()
                         .w(SIDEBAR_RESIZE_HANDLE_SIZE)
@@ -817,6 +877,12 @@ impl Render for MultiWorkspace {
             None
         };
 
+        let (left_sidebar, right_sidebar) = if sidebar_on_right {
+            (None, sidebar)
+        } else {
+            (sidebar, None)
+        };
+
         let ui_font = theme::setup_ui_font(window, cx);
         let text_color = cx.theme().colors().text;
 
@@ -855,16 +921,23 @@ impl Render for MultiWorkspace {
                     self.sidebar_open() && self.multi_workspace_enabled(cx),
                     |this| {
                         this.on_drag_move(cx.listener(
-                            |this: &mut Self, e: &DragMoveEvent<DraggedSidebar>, _window, cx| {
+                            move |this: &mut Self,
+                                  e: &DragMoveEvent<DraggedSidebar>,
+                                  window,
+                                  cx| {
                                 if let Some(sidebar) = &this.sidebar {
-                                    let new_width = e.event.position.x;
+                                    let new_width = if sidebar_on_right {
+                                        window.bounds().size.width - e.event.position.x
+                                    } else {
+                                        e.event.position.x
+                                    };
                                     sidebar.set_width(Some(new_width), cx);
                                 }
                             },
                         ))
-                        .children(sidebar)
                     },
                 )
+                .children(left_sidebar)
                 .child(
                     div()
                         .flex()
@@ -873,11 +946,13 @@ impl Render for MultiWorkspace {
                         .overflow_hidden()
                         .child(self.workspace().clone()),
                 )
+                .children(right_sidebar)
                 .child(self.workspace().read(cx).modal_layer.clone()),
             window,
             cx,
             Tiling {
-                left: multi_workspace_enabled && self.sidebar_open(),
+                left: !sidebar_on_right && multi_workspace_enabled && self.sidebar_open(),
+                right: sidebar_on_right && multi_workspace_enabled && self.sidebar_open(),
                 ..Tiling::default()
             },
         )

crates/workspace/src/status_bar.rs πŸ”—

@@ -1,7 +1,10 @@
-use crate::{ItemHandle, MultiWorkspace, Pane, ToggleWorkspaceSidebar};
+use crate::{
+    ItemHandle, MultiWorkspace, Pane, SidebarSide, ToggleWorkspaceSidebar,
+    sidebar_side_context_menu,
+};
 use gpui::{
-    AnyView, App, Context, Decorations, Entity, IntoElement, ParentElement, Render, Styled,
-    Subscription, Window,
+    AnyView, App, Context, Corner, Decorations, Entity, IntoElement, ParentElement, Render, Styled,
+    Subscription, WeakEntity, Window,
 };
 use std::any::TypeId;
 use theme::CLIENT_SIDE_DECORATION_ROUNDING;
@@ -29,18 +32,45 @@ trait StatusItemViewHandle: Send {
     fn item_type(&self) -> TypeId;
 }
 
+#[derive(Default)]
+struct SidebarStatus {
+    open: bool,
+    side: SidebarSide,
+    has_notifications: bool,
+    show_toggle: bool,
+}
+
+impl SidebarStatus {
+    fn query(multi_workspace: &Option<WeakEntity<MultiWorkspace>>, cx: &App) -> Self {
+        multi_workspace
+            .as_ref()
+            .and_then(|mw| mw.upgrade())
+            .map(|mw| {
+                let mw = mw.read(cx);
+                let enabled = mw.multi_workspace_enabled(cx);
+                Self {
+                    open: mw.sidebar_open() && enabled,
+                    side: mw.sidebar_side(cx),
+                    has_notifications: mw.sidebar_has_notifications(cx),
+                    show_toggle: enabled,
+                }
+            })
+            .unwrap_or_default()
+    }
+}
+
 pub struct StatusBar {
     left_items: Vec<Box<dyn StatusItemViewHandle>>,
     right_items: Vec<Box<dyn StatusItemViewHandle>>,
     active_pane: Entity<Pane>,
+    multi_workspace: Option<WeakEntity<MultiWorkspace>>,
     _observe_active_pane: Subscription,
-    workspace_sidebar_open: bool,
-    sidebar_has_notifications: bool,
-    show_sidebar_toggle: bool,
 }
 
 impl Render for StatusBar {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let sidebar = SidebarStatus::query(&self.multi_workspace, cx);
+
         h_flex()
             .w_full()
             .justify_between()
@@ -50,11 +80,14 @@ impl Render for StatusBar {
             .map(|el| match window.window_decorations() {
                 Decorations::Server => el,
                 Decorations::Client { tiling, .. } => el
-                    .when(!(tiling.bottom || tiling.right), |el| {
-                        el.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
-                    })
                     .when(
-                        !(tiling.bottom || tiling.left) && !self.workspace_sidebar_open,
+                        !(tiling.bottom || tiling.right)
+                            && !(sidebar.open && sidebar.side == SidebarSide::Right),
+                        |el| el.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING),
+                    )
+                    .when(
+                        !(tiling.bottom || tiling.left)
+                            && !(sidebar.open && sidebar.side == SidebarSide::Left),
                         |el| el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING),
                     )
                     // This border is to avoid a transparent gap in the rounded corners
@@ -62,44 +95,77 @@ impl Render for StatusBar {
                     .border_b(px(1.0))
                     .border_color(cx.theme().colors().status_bar_background),
             })
-            .child(self.render_left_tools(cx))
-            .child(self.render_right_tools())
+            .child(self.render_left_tools(&sidebar, cx))
+            .child(self.render_right_tools(&sidebar, cx))
     }
 }
 
 impl StatusBar {
-    fn render_left_tools(&self, cx: &mut Context<Self>) -> impl IntoElement {
+    fn render_left_tools(
+        &self,
+        sidebar: &SidebarStatus,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
         h_flex()
             .gap_1()
             .min_w_0()
             .overflow_x_hidden()
             .when(
-                self.show_sidebar_toggle && !self.workspace_sidebar_open,
-                |this| this.child(self.render_sidebar_toggle(cx)),
+                sidebar.show_toggle && !sidebar.open && sidebar.side == SidebarSide::Left,
+                |this| this.child(self.render_sidebar_toggle(sidebar, cx)),
             )
             .children(self.left_items.iter().map(|item| item.to_any()))
     }
 
-    fn render_right_tools(&self) -> impl IntoElement {
+    fn render_right_tools(
+        &self,
+        sidebar: &SidebarStatus,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
         h_flex()
             .flex_shrink_0()
             .gap_1()
             .overflow_x_hidden()
             .children(self.right_items.iter().rev().map(|item| item.to_any()))
+            .when(
+                sidebar.show_toggle && !sidebar.open && sidebar.side == SidebarSide::Right,
+                |this| this.child(self.render_sidebar_toggle(sidebar, cx)),
+            )
     }
 
-    fn render_sidebar_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
-        h_flex()
-            .gap_0p5()
-            .child(
+    fn render_sidebar_toggle(
+        &self,
+        sidebar: &SidebarStatus,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        let on_right = sidebar.side == SidebarSide::Right;
+        let has_notifications = sidebar.has_notifications;
+        let indicator_border = cx.theme().colors().status_bar_background;
+
+        let toggle = sidebar_side_context_menu("sidebar-status-toggle-menu", cx)
+            .anchor(if on_right {
+                Corner::BottomRight
+            } else {
+                Corner::BottomLeft
+            })
+            .attach(if on_right {
+                Corner::TopRight
+            } else {
+                Corner::TopLeft
+            })
+            .trigger(move |_is_active, _window, _cx| {
                 IconButton::new(
                     "toggle-workspace-sidebar",
-                    IconName::ThreadsSidebarLeftClosed,
+                    if on_right {
+                        IconName::ThreadsSidebarRightClosed
+                    } else {
+                        IconName::ThreadsSidebarLeftClosed
+                    },
                 )
                 .icon_size(IconSize::Small)
-                .when(self.sidebar_has_notifications, |this| {
+                .when(has_notifications, |this| {
                     this.indicator(Indicator::dot().color(Color::Accent))
-                        .indicator_border_color(Some(cx.theme().colors().status_bar_background))
+                        .indicator_border_color(Some(indicator_border))
                 })
                 .tooltip(move |_, cx| {
                     Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx)
@@ -110,41 +176,47 @@ impl StatusBar {
                             multi_workspace.toggle_sidebar(window, cx);
                         });
                     }
-                }),
-            )
-            .child(Divider::vertical().color(ui::DividerColor::Border))
+                })
+            });
+
+        h_flex()
+            .gap_0p5()
+            .when(on_right, |this| {
+                this.child(Divider::vertical().color(ui::DividerColor::Border))
+            })
+            .child(toggle)
+            .when(!on_right, |this| {
+                this.child(Divider::vertical().color(ui::DividerColor::Border))
+            })
     }
 }
 
 impl StatusBar {
-    pub fn new(active_pane: &Entity<Pane>, window: &mut Window, cx: &mut Context<Self>) -> Self {
+    pub fn new(
+        active_pane: &Entity<Pane>,
+        multi_workspace: Option<WeakEntity<MultiWorkspace>>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
         let mut this = Self {
             left_items: Default::default(),
             right_items: Default::default(),
             active_pane: active_pane.clone(),
+            multi_workspace,
             _observe_active_pane: cx.observe_in(active_pane, window, |this, _, window, cx| {
                 this.update_active_pane_item(window, cx)
             }),
-            workspace_sidebar_open: false,
-            sidebar_has_notifications: false,
-            show_sidebar_toggle: false,
         };
         this.update_active_pane_item(window, cx);
         this
     }
 
-    pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context<Self>) {
-        self.workspace_sidebar_open = open;
-        cx.notify();
-    }
-
-    pub fn set_sidebar_has_notifications(&mut self, has: bool, cx: &mut Context<Self>) {
-        self.sidebar_has_notifications = has;
-        cx.notify();
-    }
-
-    pub fn set_show_sidebar_toggle(&mut self, show: bool, cx: &mut Context<Self>) {
-        self.show_sidebar_toggle = show;
+    pub fn set_multi_workspace(
+        &mut self,
+        multi_workspace: WeakEntity<MultiWorkspace>,
+        cx: &mut Context<Self>,
+    ) {
+        self.multi_workspace = Some(multi_workspace);
         cx.notify();
     }
 

crates/workspace/src/workspace.rs πŸ”—

@@ -1,3 +1,4 @@
+pub mod active_file_name;
 pub mod dock;
 pub mod history_manager;
 pub mod invalid_item_view;
@@ -29,7 +30,7 @@ pub use dock::Panel;
 pub use multi_workspace::{
     CloseWorkspaceSidebar, DraggedSidebar, FocusWorkspaceSidebar, MultiWorkspace,
     MultiWorkspaceEvent, NextWorkspace, PreviousWorkspace, Sidebar, SidebarHandle,
-    ToggleWorkspaceSidebar,
+    SidebarRenderState, SidebarSide, ToggleWorkspaceSidebar, sidebar_side_context_menu,
 };
 pub use path_list::{PathList, SerializedPathList};
 pub use toast_layer::{ToastAction, ToastLayer, ToastView};
@@ -1342,6 +1343,7 @@ pub struct Workspace {
     removing: bool,
     _panels_task: Option<Task<Result<()>>>,
     sidebar_focus_handle: Option<FocusHandle>,
+    multi_workspace: Option<WeakEntity<MultiWorkspace>>,
 }
 
 impl EventEmitter<Event> for Workspace {}
@@ -1626,8 +1628,13 @@ impl Workspace {
         let left_dock_buttons = cx.new(|cx| PanelButtons::new(left_dock.clone(), cx));
         let bottom_dock_buttons = cx.new(|cx| PanelButtons::new(bottom_dock.clone(), cx));
         let right_dock_buttons = cx.new(|cx| PanelButtons::new(right_dock.clone(), cx));
+        let multi_workspace = window
+            .root::<MultiWorkspace>()
+            .flatten()
+            .map(|mw| mw.downgrade());
         let status_bar = cx.new(|cx| {
-            let mut status_bar = StatusBar::new(&center_pane.clone(), window, cx);
+            let mut status_bar =
+                StatusBar::new(&center_pane.clone(), multi_workspace.clone(), window, cx);
             status_bar.add_left_item(left_dock_buttons, window, cx);
             status_bar.add_right_item(right_dock_buttons, window, cx);
             status_bar.add_right_item(bottom_dock_buttons, window, cx);
@@ -1754,6 +1761,7 @@ impl Workspace {
             last_open_dock_positions: Vec::new(),
             removing: false,
             sidebar_focus_handle: None,
+            multi_workspace,
         }
     }
 
@@ -2127,6 +2135,13 @@ impl Workspace {
         }
     }
 
+    pub fn agent_panel_position(&self, cx: &App) -> Option<DockPosition> {
+        self.all_docks().into_iter().find_map(|dock| {
+            let dock = dock.read(cx);
+            dock.has_agent_panel(cx).then_some(dock.position())
+        })
+    }
+
     pub fn panel_size_state<T: Panel>(&self, cx: &App) -> Option<dock::PanelSizeState> {
         self.all_docks().into_iter().find_map(|dock| {
             let dock = dock.read(cx);
@@ -2193,30 +2208,29 @@ impl Workspace {
         did_set
     }
 
-    pub fn flexible_dock_size(
-        &self,
-        position: DockPosition,
-        ratio: f32,
-        window: &Window,
-        cx: &App,
-    ) -> Option<Pixels> {
-        if position.axis() != Axis::Horizontal {
-            return None;
-        }
+    fn dock_size(&self, dock: &Dock, window: &Window, cx: &App) -> Option<Pixels> {
+        let panel = dock.active_panel()?;
+        let size_state = dock
+            .stored_panel_size_state(panel.as_ref())
+            .unwrap_or_default();
+        let position = dock.position();
 
-        let available_width = self.available_width_for_horizontal_dock(position, window, cx)?;
-        Some((available_width * ratio.clamp(0.0, 1.0)).max(RESIZE_HANDLE_SIZE))
-    }
+        if position.axis() == Axis::Horizontal
+            && panel.supports_flexible_size(window, cx)
+            && let Some(ratio) = size_state
+                .flexible_size_ratio
+                .or_else(|| self.default_flexible_dock_ratio(position))
+            && let Some(available_width) =
+                self.available_width_for_horizontal_dock(position, window, cx)
+        {
+            return Some((available_width * ratio.clamp(0.0, 1.0)).max(RESIZE_HANDLE_SIZE));
+        }
 
-    pub fn resolved_dock_panel_size(
-        &self,
-        dock: &Dock,
-        panel: &dyn PanelHandle,
-        window: &Window,
-        cx: &App,
-    ) -> Pixels {
-        let size_state = dock.stored_panel_size_state(panel).unwrap_or_default();
-        dock::resolve_panel_size(size_state, panel, dock.position(), self, window, cx)
+        Some(
+            size_state
+                .size
+                .unwrap_or_else(|| panel.default_size(window, cx)),
+        )
     }
 
     pub fn flexible_dock_ratio_for_size(
@@ -2327,20 +2341,6 @@ impl Workspace {
         &self.status_bar
     }
 
-    pub fn set_workspace_sidebar_open(
-        &self,
-        open: bool,
-        has_notifications: bool,
-        show_toggle: bool,
-        cx: &mut App,
-    ) {
-        self.status_bar.update(cx, |status_bar, cx| {
-            status_bar.set_workspace_sidebar_open(open, cx);
-            status_bar.set_sidebar_has_notifications(has_notifications, cx);
-            status_bar.set_show_sidebar_toggle(show_toggle, cx);
-        });
-    }
-
     pub fn set_sidebar_focus_handle(&mut self, handle: Option<FocusHandle>) {
         self.sidebar_focus_handle = handle;
     }
@@ -2349,6 +2349,21 @@ impl Workspace {
         StatusBarSettings::get_global(cx).show
     }
 
+    pub fn multi_workspace(&self) -> Option<&WeakEntity<MultiWorkspace>> {
+        self.multi_workspace.as_ref()
+    }
+
+    pub fn set_multi_workspace(
+        &mut self,
+        multi_workspace: WeakEntity<MultiWorkspace>,
+        cx: &mut App,
+    ) {
+        self.status_bar.update(cx, |status_bar, cx| {
+            status_bar.set_multi_workspace(multi_workspace.clone(), cx);
+        });
+        self.multi_workspace = Some(multi_workspace);
+    }
+
     pub fn app_state(&self) -> &Arc<AppState> {
         &self.app_state
     }
@@ -4892,10 +4907,7 @@ impl Workspace {
 
         if let Some(dock_entity) = active_dock {
             let dock = dock_entity.read(cx);
-            let Some(panel_size) = dock
-                .active_panel()
-                .map(|panel| self.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx))
-            else {
+            let Some(panel_size) = self.dock_size(&dock, window, cx) else {
                 return;
             };
             match dock.position() {
@@ -7258,14 +7270,46 @@ impl Workspace {
             leader_border_for_pane(follower_states, &pane, window, cx)
         });
 
-        Some(
-            div()
-                .flex()
-                .flex_none()
-                .overflow_hidden()
-                .child(dock.clone())
-                .children(leader_border),
-        )
+        let mut container = div()
+            .flex()
+            .overflow_hidden()
+            .flex_none()
+            .child(dock.clone())
+            .children(leader_border);
+
+        // Apply sizing only when the dock is open. When closed the dock is still
+        // included in the element tree so its focus handle remains mounted β€” without
+        // this, toggle_panel_focus cannot focus the panel when the dock is closed.
+        let dock = dock.read(cx);
+        if let Some(panel) = dock.visible_panel() {
+            let size_state = dock.stored_panel_size_state(panel.as_ref());
+            if position.axis() == Axis::Horizontal {
+                if let Some(ratio) = size_state
+                    .and_then(|state| state.flexible_size_ratio)
+                    .or_else(|| self.default_flexible_dock_ratio(position))
+                    && panel.supports_flexible_size(window, cx)
+                {
+                    let ratio = ratio.clamp(0.001, 0.999);
+                    let grow = ratio / (1.0 - ratio);
+                    let style = container.style();
+                    style.flex_grow = Some(grow);
+                    style.flex_shrink = Some(1.0);
+                    style.flex_basis = Some(relative(0.).into());
+                } else {
+                    let size = size_state
+                        .and_then(|state| state.size)
+                        .unwrap_or_else(|| panel.default_size(window, cx));
+                    container = container.w(size);
+                }
+            } else {
+                let size = size_state
+                    .and_then(|state| state.size)
+                    .unwrap_or_else(|| panel.default_size(window, cx));
+                container = container.h(size);
+            }
+        }
+
+        Some(container)
     }
 
     pub fn for_window(window: &Window, cx: &App) -> Option<Entity<Workspace>> {
@@ -7335,18 +7379,17 @@ impl Workspace {
         }
     }
 
-    fn adjust_dock_size_by_px(
+    fn resize_dock(
         &mut self,
-        panel_size: Pixels,
         dock_pos: DockPosition,
-        px: Pixels,
+        new_size: Pixels,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
         match dock_pos {
-            DockPosition::Left => self.resize_left_dock(panel_size + px, window, cx),
-            DockPosition::Right => self.resize_right_dock(panel_size + px, window, cx),
-            DockPosition::Bottom => self.resize_bottom_dock(panel_size + px, window, cx),
+            DockPosition::Left => self.resize_left_dock(new_size, window, cx),
+            DockPosition::Right => self.resize_right_dock(new_size, window, cx),
+            DockPosition::Bottom => self.resize_bottom_dock(new_size, window, cx),
         }
     }
 
@@ -7790,14 +7833,10 @@ fn adjust_active_dock_size_by_px(
         return;
     };
     let dock = active_dock.read(cx);
-    let Some(panel_size) = dock
-        .active_panel()
-        .map(|panel| workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx))
-    else {
+    let Some(panel_size) = workspace.dock_size(&dock, window, cx) else {
         return;
     };
-    let dock_pos = dock.position();
-    workspace.adjust_dock_size_by_px(panel_size, dock_pos, px, window, cx);
+    workspace.resize_dock(dock.position(), panel_size + px, window, cx);
 }
 
 fn adjust_open_docks_size_by_px(
@@ -7812,22 +7851,18 @@ fn adjust_open_docks_size_by_px(
         .filter_map(|dock_entity| {
             let dock = dock_entity.read(cx);
             if dock.is_open() {
-                let panel_size = dock.active_panel().map(|panel| {
-                    workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)
-                })?;
                 let dock_pos = dock.position();
-                Some((panel_size, dock_pos, px))
+                let panel_size = workspace.dock_size(&dock, window, cx)?;
+                Some((dock_pos, panel_size + px))
             } else {
                 None
             }
         })
         .collect::<Vec<_>>();
 
-    docks
-        .into_iter()
-        .for_each(|(panel_size, dock_pos, offset)| {
-            workspace.adjust_dock_size_by_px(panel_size, dock_pos, offset, window, cx);
-        });
+    for (position, new_size) in docks {
+        workspace.resize_dock(position, new_size, window, cx);
+    }
 }
 
 impl Focusable for Workspace {
@@ -12270,11 +12305,8 @@ mod tests {
 
                 let dock = workspace.right_dock().read(cx);
                 let workspace_width = workspace.bounds.size.width;
-                let initial_width = dock
-                    .active_panel()
-                    .map(|panel| {
-                        workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)
-                    })
+                let initial_width = workspace
+                    .dock_size(&dock, window, cx)
                     .expect("flexible dock should have an initial width");
 
                 assert_eq!(initial_width, workspace_width / 2.);
@@ -12282,11 +12314,8 @@ mod tests {
                 workspace.resize_right_dock(px(300.), window, cx);
 
                 let dock = workspace.right_dock().read(cx);
-                let resized_width = dock
-                    .active_panel()
-                    .map(|panel| {
-                        workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)
-                    })
+                let resized_width = workspace
+                    .dock_size(&dock, window, cx)
                     .expect("flexible dock should keep its resized width");
 
                 assert_eq!(resized_width, px(300.));
@@ -12306,9 +12335,8 @@ mod tests {
             workspace.toggle_dock(DockPosition::Right, window, cx);
 
             let dock = workspace.right_dock().read(cx);
-            let reopened_width = dock
-                .active_panel()
-                .map(|panel| workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx))
+            let reopened_width = workspace
+                .dock_size(&dock, window, cx)
                 .expect("flexible dock should restore when reopened");
 
             assert_eq!(reopened_width, resized_width);
@@ -12335,9 +12363,8 @@ mod tests {
             );
 
             let dock = workspace.right_dock().read(cx);
-            let split_width = dock
-                .active_panel()
-                .map(|panel| workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx))
+            let split_width = workspace
+                .dock_size(&dock, window, cx)
                 .expect("flexible dock should keep its user-resized proportion");
 
             assert_eq!(split_width, px(300.));
@@ -12345,9 +12372,8 @@ mod tests {
             workspace.bounds.size.width = px(1600.);
 
             let dock = workspace.right_dock().read(cx);
-            let resized_window_width = dock
-                .active_panel()
-                .map(|panel| workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx))
+            let resized_window_width = workspace
+                .dock_size(&dock, window, cx)
                 .expect("flexible dock should preserve proportional size on window resize");
 
             assert_eq!(
@@ -12517,9 +12543,8 @@ mod tests {
             workspace.toggle_dock(DockPosition::Left, window, cx);
 
             let left_dock = workspace.left_dock().read(cx);
-            let left_width = left_dock
-                .active_panel()
-                .map(|p| workspace.resolved_dock_panel_size(&left_dock, p.as_ref(), window, cx))
+            let left_width = workspace
+                .dock_size(&left_dock, window, cx)
                 .expect("left dock should have an active panel");
 
             assert_eq!(
@@ -12541,9 +12566,8 @@ mod tests {
             );
 
             let left_dock = workspace.left_dock().read(cx);
-            let left_width = left_dock
-                .active_panel()
-                .map(|p| workspace.resolved_dock_panel_size(&left_dock, p.as_ref(), window, cx))
+            let left_width = workspace
+                .dock_size(&left_dock, window, cx)
                 .expect("left dock should still have an active panel after vertical split");
 
             assert_eq!(
@@ -12562,15 +12586,13 @@ mod tests {
             workspace.toggle_dock(DockPosition::Right, window, cx);
 
             let right_dock = workspace.right_dock().read(cx);
-            let right_width = right_dock
-                .active_panel()
-                .map(|p| workspace.resolved_dock_panel_size(&right_dock, p.as_ref(), window, cx))
+            let right_width = workspace
+                .dock_size(&right_dock, window, cx)
                 .expect("right dock should have an active panel");
 
             let left_dock = workspace.left_dock().read(cx);
-            let left_width = left_dock
-                .active_panel()
-                .map(|p| workspace.resolved_dock_panel_size(&left_dock, p.as_ref(), window, cx))
+            let left_width = workspace
+                .dock_size(&left_dock, window, cx)
                 .expect("left dock should still have an active panel");
 
             let available_width = workspace.bounds.size.width - right_width;
@@ -12634,8 +12656,8 @@ mod tests {
                 panel_1.panel_id()
             );
             assert_eq!(
-                left_dock.read(cx).active_panel_size(window, cx).unwrap(),
-                px(300.)
+                workspace.dock_size(&left_dock.read(cx), window, cx),
+                Some(px(300.))
             );
 
             workspace.resize_left_dock(px(1337.), window, cx);
@@ -12668,7 +12690,12 @@ mod tests {
                 panel_1.panel_id()
             );
             assert_eq!(
-                right_dock.read(cx).active_panel_size(window, cx).unwrap(),
+                right_dock
+                    .read(cx)
+                    .active_panel_size()
+                    .unwrap()
+                    .size
+                    .unwrap(),
                 px(1337.)
             );
 
@@ -12706,8 +12733,8 @@ mod tests {
                 panel_1.panel_id()
             );
             assert_eq!(
-                left_dock.read(cx).active_panel_size(window, cx).unwrap(),
-                px(1337.)
+                workspace.dock_size(&left_dock.read(cx), window, cx),
+                Some(px(1337.))
             );
             // And the right dock should be closed as it no longer has any panels.
             assert!(!workspace.right_dock().read(cx).is_open());
@@ -12723,8 +12750,8 @@ mod tests {
             // since the panel orientation changed from vertical to horizontal.
             let bottom_dock = workspace.bottom_dock();
             assert_eq!(
-                bottom_dock.read(cx).active_panel_size(window, cx).unwrap(),
-                px(300.),
+                workspace.dock_size(&bottom_dock.read(cx), window, cx),
+                Some(px(300.))
             );
             // Close bottom dock and move panel_1 back to the left.
             bottom_dock.update(cx, |bottom_dock, cx| {

crates/workspace/src/workspace_settings.rs πŸ”—

@@ -132,6 +132,7 @@ impl Settings for TabBarSettings {
 #[derive(Deserialize, RegisterSetting)]
 pub struct StatusBarSettings {
     pub show: bool,
+    pub show_active_file: bool,
     pub active_language_button: bool,
     pub cursor_position_button: bool,
     pub line_endings_button: bool,
@@ -143,6 +144,7 @@ impl Settings for StatusBarSettings {
         let status_bar = content.status_bar.clone().unwrap();
         StatusBarSettings {
             show: status_bar.show.unwrap(),
+            show_active_file: status_bar.show_active_file.unwrap(),
             active_language_button: status_bar.active_language_button.unwrap(),
             cursor_position_button: status_bar.cursor_position_button.unwrap(),
             line_endings_button: status_bar.line_endings_button.unwrap(),

crates/zed/Cargo.toml πŸ”—

@@ -2,7 +2,7 @@
 description = "The fast, collaborative code editor."
 edition.workspace = true
 name = "zed"
-version = "0.230.0"
+version = "0.231.0"
 publish.workspace = true
 license = "GPL-3.0-or-later"
 authors = ["Zed Team <hi@zed.dev>"]

crates/zed/src/zed.rs πŸ”—

@@ -478,6 +478,7 @@ pub fn initialize_workspace(
         let search_button = cx.new(|_| search::search_status_button::SearchButton::new());
         let diagnostic_summary =
             cx.new(|cx| diagnostics::items::DiagnosticIndicator::new(workspace, cx));
+        let active_file_name = cx.new(|_| workspace::active_file_name::ActiveFileName::new());
         let activity_indicator = activity_indicator::ActivityIndicator::new(
             workspace,
             workspace.project().read(cx).languages().clone(),
@@ -510,6 +511,7 @@ pub fn initialize_workspace(
             status_bar.add_left_item(search_button, window, cx);
             status_bar.add_left_item(lsp_button, window, cx);
             status_bar.add_left_item(diagnostic_summary, window, cx);
+            status_bar.add_left_item(active_file_name, window, cx);
             status_bar.add_left_item(activity_indicator, window, cx);
             status_bar.add_right_item(edit_prediction_ui, window, cx);
             status_bar.add_right_item(active_buffer_encoding, window, cx);

crates/zed/src/zed/edit_prediction_registry.rs πŸ”—

@@ -141,9 +141,7 @@ fn edit_prediction_provider_config_for_settings(cx: &App) -> Option<EditPredicti
                 ))
             }
         }
-        EditPredictionProvider::Sweep => Some(EditPredictionProviderConfig::Zed(
-            EditPredictionModel::Sweep,
-        )),
+
         EditPredictionProvider::Mercury => Some(EditPredictionProviderConfig::Zed(
             EditPredictionModel::Mercury,
         )),
@@ -183,7 +181,6 @@ impl EditPredictionProviderConfig {
             EditPredictionProviderConfig::Zed(model) => match model {
                 EditPredictionModel::Zeta => "Zeta",
                 EditPredictionModel::Fim { .. } => "FIM",
-                EditPredictionModel::Sweep => "Sweep",
                 EditPredictionModel::Mercury => "Mercury",
             },
         }

docs/README.md πŸ”—

@@ -53,6 +53,14 @@ This will output a code element like: `<code>Cmd + , | Ctrl + ,</code>`. We then
 
 By using the action name, we can ensure that the keybinding is always up-to-date rather than hardcoding the keybinding.
 
+#### Keymap Overlays
+
+`{#kb:keymap_name scope::Action}` - e.g., `{#kb:jetbrains editor::GoToDefinition}`.
+
+This resolves the keybinding from a keymap overlay (e.g., JetBrains) first, falling back to the default keymap if the overlay doesn't define a binding for that action. This is useful for sections where the documentation expects a special base keymap to be configured.
+
+Supported overlays: `jetbrains`.
+
 ### Actions
 
 `{#action scope::Action}` - e.g., `{#action zed::OpenSettings}`.

docs/src/ai/edit-prediction.md πŸ”—

@@ -1,21 +1,23 @@
 ---
-title: AI Code Completion in Zed - Zeta, Copilot, Sweep, Mercury Coder
-description: Set up AI code completions in Zed with Zeta (built-in), GitHub Copilot, Sweep, Codestral, or Mercury Coder. Multi-line predictions on every keystroke.
+title: AI Code Completion in Zed - Zeta, Copilot, Codestral, Mercury Coder
+description: Set up AI code completions in Zed with Zeta (built-in), GitHub Copilot, Codestral, or Mercury Coder. Multi-line predictions on every keystroke.
 ---
 
 # Edit Prediction
 
 Edit Prediction is how Zed's AI code completions work: an LLM predicts the code you want to write.
-Each keystroke sends a new request to the edit prediction provider, which returns individual or multi-line suggestions that can be quickly accepted by pressing `tab`.
+Each keystroke sends a new request to the edit prediction provider, which returns individual or multi-line suggestions you accept by pressing `tab`.
 
-The default provider is [Zeta, a proprietary open source and open dataset model](https://huggingface.co/zed-industries/zeta), but you can also use [other providers](#other-providers) like GitHub Copilot, Sweep, Mercury Coder, and Codestral.
+The default provider is [Zeta, a proprietary open source and open dataset model](https://huggingface.co/zed-industries/zeta), but you can also use [other providers](#other-providers) like GitHub Copilot, Mercury Coder, and Codestral.
 
 ## Configuring Zeta
 
 To use Zeta, [sign in](../authentication.md#what-features-require-signing-in).
 Once signed in, predictions appear as you type.
 
-You can confirm that Zeta is properly configured either by verifying whether you have the following code in your settings file:
+You can confirm that Zeta is properly configured by opening the [Settings Editor](zed://settings/edit_predictions.providers) (`Cmd+,` on macOS or `Ctrl+,` on Linux/Windows) and searching for `edit_predictions`. The `provider` field should be set to `Zed AI`.
+
+Or verify this in your settings.json:
 
 ```json [settings]
 {
@@ -33,7 +35,7 @@ The free plan includes 2,000 Zeta predictions per month. The [Pro plan](../ai/pl
 
 ### Switching Modes {#switching-modes}
 
-Zed's Edit Prediction comes with two different display modes:
+Edit Prediction has two display modes:
 
 1. `eager` (default): predictions are displayed inline as long as it doesn't conflict with language server completions
 2. `subtle`: predictions only appear inline when holding a modifier key (`alt` by default)
@@ -52,191 +54,93 @@ Or directly via the UI through the status bar menu:
 
 > Note that edit prediction modes work with any prediction provider.
 
-### Conflict With Other `tab` Actions {#edit-predictions-conflict}
-
-By default, when `tab` would normally perform a different action, Zed requires a modifier key to accept predictions:
+## Default Key Bindings
 
-1. When the language server completions menu is visible.
-2. When your cursor isn't at the right indentation level.
+On macOS and Windows, you can accept edit predictions with `alt-tab`. On Linux, `alt-tab` is often used by the window manager for switching windows, so `alt-l` is the default key binding for edit predictions.
 
-In these cases, `alt-tab` is used instead to accept the prediction. When the language server completions menu is open, holding `alt` first will cause it to temporarily disappear in order to preview the prediction within the buffer.
-
-On Linux, `alt-tab` is often used by the window manager for switching windows, so `alt-l` is provided as the default binding for accepting predictions. `tab` and `alt-tab` also work, but aren't displayed by default.
+In `eager` mode, you can also use the `tab` key to accept edit predictions, unless the completion menu is open, in which case `tab` accepts LSP completions. To use `tab` to insert whitespace, you need to dismiss the prediction with {#kb editor::Cancel} before hitting `tab`.
 
 {#action editor::AcceptNextWordEditPrediction} ({#kb editor::AcceptNextWordEditPrediction}) can be used to accept the current edit prediction up to the next word boundary.
 {#action editor::AcceptNextLineEditPrediction} ({#kb editor::AcceptNextLineEditPrediction}) can be used to accept the current edit prediction up to the new line boundary.
 
 ## Configuring Edit Prediction Keybindings {#edit-predictions-keybinding}
 
-By default, `tab` is used to accept edit predictions. You can use another keybinding by inserting this in your keymap:
-
-```json [keymap]
-{
-  "context": "Editor && edit_prediction",
-  "bindings": {
-    // Here we also allow `alt-enter` to accept the prediction
-    "alt-enter": "editor::AcceptEditPrediction"
-  }
-}
-```
-
-When there's a [conflict with the `tab` key](#edit-predictions-conflict), Zed uses a different key context to accept keybindings (`edit_prediction_conflict`).
-If you want to use a different one, you can insert this in your keymap:
-
-```json [keymap]
-{
-  "context": "Editor && edit_prediction_conflict",
-  "bindings": {
-    "ctrl-enter": "editor::AcceptEditPrediction" // Example of a modified keybinding
-  }
-}
-```
-
-If your keybinding contains a modifier (`ctrl` in the example above), it will also be used to preview the edit prediction and temporarily hide the language server completion menu.
-
-You can also bind this action to keybind without a modifier.
-In that case, Zed will use the default modifier (`alt`) to preview the edit prediction.
-
-```json [keymap]
-{
-  "context": "Editor && edit_prediction_conflict",
-  "bindings": {
-    // Here we bind tab to accept even when there's a language server completion
-    // or the cursor isn't at the correct indentation level
-    "tab": "editor::AcceptEditPrediction"
-  }
-}
-```
-
-To maintain the use of the modifier key for accepting predictions when there is a language server completions menu, but allow `tab` to accept predictions regardless of cursor position, you can specify the context further with `showing_completions`:
-
-```json [keymap]
-{
-  "context": "Editor && edit_prediction_conflict && !showing_completions",
-  "bindings": {
-    // Here we don't require a modifier unless there's a language server completion
-    "tab": "editor::AcceptEditPrediction"
-  }
-}
-```
-
 ### Keybinding Example: Always Use Tab
 
-If you want to use `tab` to always accept edit predictions, you can use the following keybinding:
-
-```json [keymap]
-{
-  "context": "Editor && edit_prediction_conflict && showing_completions",
-  "bindings": {
-    "tab": "editor::AcceptEditPrediction"
-  }
-}
-```
-
-This will make `tab` work to accept edit predictions _even when_ you're also seeing language server completions.
-That means that you need to rely on `enter` for accepting the latter.
+To always use `tab` for accepting edit predictions, regardless of whether the LSP completions menu is open, you can add the following to your keymap:
 
-### Keybinding Example: Always Use Alt-Tab
+Open the keymap editor with {#action zed::OpenKeymap} ({#kb zed::OpenKeymap}), search for `AcceptEditPrediction`, right click on the binding for `tab` and hit `edit`. Then change the context the binding is active in to just `Editor && edit_prediction` and save it.
 
-The keybinding example below causes `alt-tab` to always be used instead of sometimes using `tab`.
-You might want this in order to have just one (alternative) keybinding to use for accepting edit predictions, since the behavior of `tab` varies based on context.
+Alternatively, you can put the following in your `keymap.json`:
 
 ```json [keymap]
+[
   {
     "context": "Editor && edit_prediction",
     "bindings": {
-      "alt-tab": "editor::AcceptEditPrediction"
-    }
-  },
-  // Bind `tab` back to its original behavior.
-  {
-    "context": "Editor",
-    "bindings": {
-      "tab": "editor::Tab"
-    }
-  },
-  {
-    "context": "Editor && showing_completions",
-    "bindings": {
-      "tab": "editor::ComposeCompletion"
+      "tab": "editor::AcceptEditPrediction"
     }
-  },
+  }
+]
 ```
 
-If you are using [Vim mode](../vim.md), then additional bindings are needed after the above to return `tab` to its original behavior:
+After that, {#kb editor::ComposeCompletion} remains available for accepting LSP completions.
 
-```json [keymap]
-  {
-    "context": "(VimControl && !menu) || vim_mode == replace || vim_mode == waiting",
-    "bindings": {
-      "tab": "vim::Tab"
-    }
-  },
-  {
-    "context": "vim_mode == literal",
-    "bindings": {
-      "tab": ["vim::Literal", ["tab", "\u0009"]]
-    }
-  },
-```
+### Keybinding Example: Always Use Alt-Tab
+
+To stop using `tab` for accepting edit predictions and always use `alt-tab` instead, unbind the default `tab` binding in the eager edit prediction context:
 
-### Keybinding Example: Displaying Tab and Alt-Tab on Linux
+Open the keymap editor with {#action zed::OpenKeymap} ({#kb zed::OpenKeymap}), search for `AcceptEditPrediction`, right click on the binding for `tab` and delete it.
 
-While `tab` and `alt-tab` are supported on Linux, `alt-l` is displayed instead.
-If your window manager does not reserve `alt-tab`, and you would prefer to use `tab` and `alt-tab`, include these bindings in `keymap.json`:
+Alternatively, you can put the following in your `keymap.json`:
 
 ```json [keymap]
+[
   {
     "context": "Editor && edit_prediction",
-    "bindings": {
-      "tab": "editor::AcceptEditPrediction",
-      // Optional: This makes the default `alt-l` binding do nothing.
-      "alt-l": null
+    "unbind": {
+      "tab": "editor::AcceptEditPrediction"
     }
-  },
-  {
-    "context": "Editor && edit_prediction_conflict",
-    "bindings": {
-      "alt-tab": "editor::AcceptEditPrediction",
-      // Optional: This makes the default `alt-l` binding do nothing.
-      "alt-l": null
-    }
-  },
+  }
+]
 ```
 
-### Missing keybind {#edit-predictions-missing-keybinding}
+After that, `alt-tab` remains available for accepting edit predictions, and on Linux `alt-l` does too unless you unbind it.
 
-Zed requires at least one keybinding for the {#action editor::AcceptEditPrediction} action in both the `Editor && edit_prediction` and `Editor && edit_prediction_conflict` contexts ([learn more above](#edit-predictions-keybinding)).
+### Keybinding Example: Rebind Both Tab and Alt-Tab
 
-If you have previously bound the default keybindings to different actions in the global context, you will not be able to preview or accept edit predictions. For example:
+To move both default accept bindings to something else, unbind them and add your replacement:
 
-```json [keymap]
-[
-  // Your keymap
-  {
-    "bindings": {
-      // Binds `alt-tab` to a different action globally
-      "alt-tab": "menu::SelectNext"
-    }
-  }
-]
-```
+Open the keymap editor with {#action zed::OpenKeymap} ({#kb zed::OpenKeymap}), search for `AcceptEditPrediction`, right click on the binding for `tab` and delete it. Then right click on the binding for `alt-tab`, select "Edit", and record your desired keystrokes before hitting saving.
 
-To fix this, you can specify your own keybinding for accepting edit predictions:
+Alternatively, you can put the following in your `keymap.json`:
 
 ```json [keymap]
 [
-  // ...
   {
-    "context": "Editor && edit_prediction_conflict",
+    "context": "Editor && edit_prediction",
+    "unbind": {
+      "alt-tab": "editor::AcceptEditPrediction",
+      // Add this as well on Windows/Linux
+      // "alt-l": "editor::AcceptEditPrediction",
+      "tab": "editor::AcceptEditPrediction"
+    },
     "bindings": {
-      "alt-l": "editor::AcceptEditPrediction"
+      "ctrl-enter": "editor::AcceptEditPrediction"
     }
   }
 ]
 ```
 
-If you would like to use the default keybinding, you can free it up by either moving yours to a more specific context or changing it to something else.
+In this case, because the binding contains the modifier `ctrl`, it will be used to preview the prediction in subtle mode, or when the completions menu is open.
+
+### Cleaning Up Older Keymap Entries
+
+If you configured edit prediction keybindings before Zed `v0.229.0`, your `keymap.json` may have entries that are now redundant.
+
+**Old tab workaround**: Before `unbind` existed, the only way to prevent `tab` from accepting edit predictions was to copy all the default non-edit-prediction `tab` bindings into your keymap alongside a custom `AcceptEditPrediction` binding. If your keymap still contains those copy-pasted entries, delete them and use a single `"unbind"` entry as shown in the examples above.
+
+**Renamed context**: The `edit_prediction_conflict` context has been replaced by `edit_prediction && (showing_completions || in_leading_whitespace)`. Zed automatically migrates any bindings that used `edit_prediction_conflict`, so no changes are required on your end.
 
 ## Disabling Automatic Edit Prediction
 
@@ -329,8 +233,8 @@ If your organization uses GitHub Copilot Enterprise, you can configure Zed to us
 
 Replace `"https://your.enterprise.domain"` with the URL provided by your GitHub Enterprise administrator (e.g., `https://foo.ghe.com`).
 
-Once set, Zed will route Copilot requests through your enterprise endpoint.
-When you sign in by clicking the Copilot icon in the status bar, you will be redirected to your configured enterprise URL to complete authentication.
+Once set, Zed routes Copilot requests through your enterprise endpoint.
+When you sign in by clicking the Copilot icon in the status bar, you are redirected to your configured enterprise URL to complete authentication.
 All other Copilot features and usage remain the same.
 
 Copilot can provide multiple completion alternatives, and these can be navigated with the following actions:
@@ -338,33 +242,11 @@ Copilot can provide multiple completion alternatives, and these can be navigated
 - {#action editor::NextEditPrediction} ({#kb editor::NextEditPrediction}): To cycle to the next edit prediction
 - {#action editor::PreviousEditPrediction} ({#kb editor::PreviousEditPrediction}): To cycle to the previous edit prediction
 
-### Sweep {#sweep}
-
-To use [Sweep](https://sweep.dev/) as your provider:
-
-1. Open the Settings Editor (`Cmd+,` on macOS, `Ctrl+,` on Linux/Windows)
-2. Search for "Edit Predictions" and click **Configure Providers**
-3. Find the Sweep section and enter your API key from the
-   [Sweep dashboard](https://app.sweep.dev/)
-
-Alternatively, click the edit prediction icon in the status bar and select
-**Configure Providers** from the menu.
-
-After adding your API key, Sweep will appear in the provider dropdown in the status bar menu, where you can select it. You can also set it directly in your settings file:
-
-```json [settings]
-{
-  "edit_predictions": {
-    "provider": "sweep"
-  }
-}
-```
-
 ### Mercury Coder {#mercury-coder}
 
 To use [Mercury Coder](https://www.inceptionlabs.ai/) by Inception Labs as your provider:
 
-1. Open the Settings Editor (`Cmd+,` on macOS, `Ctrl+,` on Linux/Windows)
+1. Open the Settings Editor ({#kb zed::OpenSettings})
 2. Search for "Edit Predictions" and click **Configure Providers**
 3. Find the Mercury section and enter your API key from the
    [Inception Labs dashboard](https://platform.inceptionlabs.ai/dashboard/api-keys)

docs/src/development/glossary.md πŸ”—

@@ -84,16 +84,16 @@ h_flex()
 - `Panel`: An `Entity` implementing the `Panel` trait. Panels can be placed in a `Dock`. In the image below: `ProjectPanel` is in the left dock, `DebugPanel` is in the bottom dock, and `AgentPanel` is in the right dock. `Editor` does not implement `Panel`.
 - `Dock`: A UI element similar to a `Pane` that can be opened and hidden. Up to three docks can be open at once: left, right, and bottom. A dock contains one or more `Panel`s, not `Pane`s.
 
-<img width="1921" height="1080" alt="Screenshot for the Pane and Dock features" src="https://github.com/user-attachments/assets/2cb1170e-2850-450d-89bb-73622b5d07b2" />
+<img width="1921" height="auto" alt="Screenshot for the Pane and Dock features" src="https://github.com/user-attachments/assets/2cb1170e-2850-450d-89bb-73622b5d07b2" />
 
 - `Project`: One or more `Worktree`s
 - `Worktree`: Represents either local or remote files.
 
-<img width="552" height="1118" alt="Screenshot for the Worktree feature" src="https://github.com/user-attachments/assets/da5c58e4-b02e-4038-9736-27e3509fdbfa" />
+<img width="552" height="auto" alt="Screenshot for the Worktree feature" src="https://github.com/user-attachments/assets/da5c58e4-b02e-4038-9736-27e3509fdbfa" />
 
 - [Multibuffer](https://zed.dev/docs/multibuffers): A list of Editors, a multi-buffer allows editing multiple files simultaneously. A multi-buffer opens when an operation in Zed returns multiple locations, examples: _search_ or _go to definition_. See project search in the image below.
 
-<img width="800" height="886" alt="Screenshot for the MultiBuffer feature" src="https://github.com/user-attachments/assets/d59dcecd-8ab6-4172-8fb6-b1fc3c3eaf9d" />
+<img width="800" height="auto" alt="Screenshot for the MultiBuffer feature" src="https://github.com/user-attachments/assets/d59dcecd-8ab6-4172-8fb6-b1fc3c3eaf9d" />
 
 ## Editor
 

docs/src/finding-navigating.md πŸ”—

@@ -23,14 +23,6 @@ Search across all files with {#kb pane::DeploySearch}. Start typing in the searc
 
 Results appear in a [multibuffer](./multibuffers.md), letting you edit matches in place.
 
-To disable automatic search and require pressing Enter instead, open the Settings Editor ({#kb zed::OpenSettings}), search for "search on input", and toggle the setting off. Or add this to your settings.json:
-
-```json
-{
-  "search_on_input": false
-}
-```
-
 ## Go to Definition
 
 Jump to where a symbol is defined with {#kb editor::GoToDefinition} (or `Cmd+Click` / `Ctrl+Click`). If there are multiple definitions, they open in a multibuffer.

docs/src/languages/elixir.md πŸ”—

@@ -7,94 +7,175 @@ description: "Configure Elixir language support in Zed, including language serve
 
 Elixir support is available through the [Elixir extension](https://github.com/zed-extensions/elixir).
 
-- Tree-sitter:
+- Tree-sitter Grammars:
   - [elixir-lang/tree-sitter-elixir](https://github.com/elixir-lang/tree-sitter-elixir)
   - [phoenixframework/tree-sitter-heex](https://github.com/phoenixframework/tree-sitter-heex)
-- Language servers:
+- Language Servers:
   - [elixir-lang/expert](https://github.com/elixir-lang/expert)
   - [elixir-lsp/elixir-ls](https://github.com/elixir-lsp/elixir-ls)
   - [elixir-tools/next-ls](https://github.com/elixir-tools/next-ls)
   - [lexical-lsp/lexical](https://github.com/lexical-lsp/lexical)
 
-## Choosing a language server
+Furthermore, the extension provides support for [EEx](https://hexdocs.pm/eex/EEx.html) (Embedded Elixir) templates and [HEEx](https://hexdocs.pm/phoenix/components.html#heex) templates, a mix of HTML and EEx used by Phoenix LiveView applications.
 
-The Elixir extension offers language server support for `expert`, `elixir-ls`, `next-ls`, and `lexical`.
+## Language Servers
 
-`elixir-ls` is enabled by default.
+The Elixir extension offers language server support for ElixirLS, Expert, Next LS, and Lexical. By default, only ElixirLS is enabled. You can change or disable the enabled language servers in your settings ({#kb zed::OpenSettings}) under Languages > Elixir/EEx/HEEx or directly within your settings file.
 
-### Expert
+Some of the language servers can also accept initialization or workspace configuration options. See the sections below for an outline of what each server supports. The configuration can be passed in your settings file via `lsp.{language-server-id}.initialization_options` and `lsp.{language-server-id}.settings` respectively.
 
-Configure language servers in Settings ({#kb zed::OpenSettings}) under Languages > Elixir, or add to your settings file:
+Visit the [Configuring Zed](../configuring-zed.md#settings-files) guide for more information on how to edit your settings file.
+
+### Using ElixirLS
+
+ElixirLS can accept workspace configuration options.
+
+The following example disables [Dialyzer](https://github.com/elixir-lsp/elixir-ls#dialyzer-integration):
+
+```json [settings]
+  "lsp": {
+    "elixir-ls": {
+      "settings": {
+        "dialyzerEnabled": false
+      }
+    }
+  }
+```
+
+See the official list of [ElixirLS configuration settings](https://github.com/elixir-lsp/elixir-ls#elixirls-configuration-settings) for all available options.
+
+### Using Expert
+
+Enable Expert by adding the following to your settings file:
 
 ```json [settings]
   "languages": {
     "Elixir": {
       "language_servers": ["expert", "!elixir-ls", "!next-ls", "!lexical", "..."]
     },
-    "HEEX": {
+    "EEx": {
       "language_servers": ["expert", "!elixir-ls", "!next-ls", "!lexical", "..."]
+    },
+    "HEEx": {
+      "language_servers": ["expert", "!elixir-ls", "!next-ls", "!lexical", "..."]
+    }
+  }
+```
+
+Expert can accept workspace configuration options.
+
+The following example sets the minimum number of characters required for a project symbol search to return results:
+
+```json [settings]
+  "lsp": {
+    "expert": {
+      "settings": {
+        "workspaceSymbols": {
+          "minQueryLength": 0
+        }
+      }
     }
   }
 ```
 
-### Next LS
+See the [Expert configuration](https://expert-lsp.org/docs/configuration/) page for all available options.
 
-Configure language servers in Settings ({#kb zed::OpenSettings}) under Languages > Elixir, or add to your settings file:
+### Using Next LS
+
+Enable Next LS by adding the following to your settings file:
 
 ```json [settings]
   "languages": {
     "Elixir": {
       "language_servers": ["next-ls", "!expert", "!elixir-ls", "!lexical", "..."]
     },
-    "HEEX": {
+    "EEx": {
+      "language_servers": ["next-ls", "!expert", "!elixir-ls", "!lexical", "..."]
+    },
+    "HEEx": {
       "language_servers": ["next-ls", "!expert", "!elixir-ls", "!lexical", "..."]
     }
   }
 ```
 
-### Lexical
+Next LS can accept initialization options.
 
-Configure language servers in Settings ({#kb zed::OpenSettings}) under Languages > Elixir, or add to your settings file:
+Completions are an experimental feature within Next LS, they are enabled by default in Zed. Disable them by adding the following to your settings file:
 
 ```json [settings]
-  "languages": {
-    "Elixir": {
-      "language_servers": ["lexical", "!expert", "!elixir-ls", "!next-ls", "..."]
-    },
-    "HEEX": {
-      "language_servers": ["lexical", "!expert", "!elixir-ls", "!next-ls", "..."]
+  "lsp": {
+    "next-ls": {
+      "initialization_options": {
+        "experimental": {
+          "completions": {
+            "enable": false
+          }
+        }
+      }
     }
   }
 ```
 
-## Setting up `elixir-ls`
+Next LS also has an extension for [Credo](https://hexdocs.pm/credo/overview.html) integration which is enabled by default. You can disable this by adding the following section to your settings file:
 
-1. Install `elixir`:
-
-```sh
-brew install elixir
+```json [settings]
+  "lsp": {
+    "next-ls": {
+      "initialization_options": {
+        "extensions": {
+          "credo": {
+            "enable": false
+          }
+        }
+      }
+    }
+  }
 ```
 
-2. Install `elixir-ls`:
+Next LS can also pass CLI options directly to Credo. The following example passes `--min-priority high` to it:
 
-```sh
-brew install elixir-ls
+```json [settings]
+  "lsp": {
+    "next-ls": {
+      "initialization_options": {
+        "extensions": {
+          "credo": {
+            "cli_options": ["--min-priority high"]
+          }
+        }
+      }
+    }
+  }
 ```
 
-3. Restart Zed
+See the [Credo Command Line Switches](https://hexdocs.pm/credo/suggest_command.html#command-line-switches) page for more CLI options.
 
-> If `elixir-ls` is not running in an elixir project, check the error log via the command palette action `zed: open log`. If you find an error message mentioning: `invalid LSP message header "Shall I install Hex? (if running non-interactively, use \"mix local.hex --force\") [Yn]`, you might need to install [`Hex`](https://hex.pm). You run `elixir-ls` from the command line and accept the prompt to install `Hex`.
+### Using Lexical
 
-### Formatting with Mix
+Enable Lexical by adding the following to your settings file:
 
-If you prefer to format your code with [Mix](https://hexdocs.pm/mix/Mix.html), configure it as an external formatter. Formatting will occur on file save.
+```json [settings]
+  "languages": {
+    "Elixir": {
+      "language_servers": ["lexical", "!expert", "!elixir-ls", "!next-ls", "..."]
+    },
+    "EEx": {
+      "language_servers": ["lexical", "!expert", "!elixir-ls", "!next-ls", "..."]
+    },
+    "HEEx": {
+      "language_servers": ["lexical", "!expert", "!elixir-ls", "!next-ls", "..."]
+    }
+  }
+```
+
+## Formatting without a language server
 
-Configure formatting in Settings ({#kb zed::OpenSettings}) under Languages > Elixir, or add to your settings file:
+If you prefer to work without a language server but would still like code formatting from [Mix](https://hexdocs.pm/mix/Mix.html), you can configure it as an external formatter by adding the following to your settings file:
 
 ```json [settings]
-{
   "languages": {
     "Elixir": {
+      "enable_language_server": false,
       "format_on_save": "on",
       "formatter": {
         "external": {
@@ -102,46 +183,41 @@ Configure formatting in Settings ({#kb zed::OpenSettings}) under Languages > Eli
           "arguments": ["format", "--stdin-filename", "{buffer_path}", "-"]
         }
       }
-    }
-  }
-}
-```
-
-### Additional workspace configuration options
-
-You can pass additional elixir-ls workspace configuration options via `lsp` settings in your settings file ([how to edit](../configuring-zed.md#settings-files)).
-
-The following example disables dialyzer:
-
-```json [settings]
-  "lsp": {
-    "elixir-ls": {
-      "settings": {
-        "dialyzerEnabled": false
+    },
+    "EEx": {
+      "enable_language_server": false,
+      "format_on_save": "on",
+      "formatter": {
+        "external": {
+          "command": "mix",
+          "arguments": ["format", "--stdin-filename", "{buffer_path}", "-"]
+        }
+      }
+    },
+    "HEEx": {
+      "enable_language_server": false,
+      "format_on_save": "on",
+      "formatter": {
+        "external": {
+          "command": "mix",
+          "arguments": ["format", "--stdin-filename", "{buffer_path}", "-"]
+        }
       }
     }
   }
 ```
 
-See [ElixirLS configuration settings](https://github.com/elixir-lsp/elixir-ls#elixirls-configuration-settings) for more options.
+## Using the Tailwind CSS Language Server with HEEx templates
 
-### HEEx
-
-Zed also supports HEEx templates. HEEx is a mix of [EEx](https://hexdocs.pm/eex/1.12.3/EEx.html) (Embedded Elixir) and HTML, and is used in Phoenix LiveView applications.
-
-- Tree-sitter: [phoenixframework/tree-sitter-heex](https://github.com/phoenixframework/tree-sitter-heex)
-
-#### Using the Tailwind CSS Language Server with HEEx
-
-To get all features (autocomplete, linting, and hover docs) from the [Tailwind CSS language server](https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme) in HEEx files, add the following to your settings file ([how to edit](../configuring-zed.md#settings-files)):
+To get all features (autocomplete, linting, and hover docs) from the [Tailwind CSS language server](https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme) in HEEx templates, add the following to your settings file:
 
 ```json [settings]
-{
   "lsp": {
     "tailwindcss-language-server": {
       "settings": {
         "includeLanguages": {
-          "phoenix-heex": "html"
+          "elixir": "html",
+          "heex": "html"
         },
         "experimental": {
           "classRegex": ["class=\"([^\"]*)\"", "class='([^']*)'"]
@@ -149,10 +225,9 @@ To get all features (autocomplete, linting, and hover docs) from the [Tailwind C
       }
     }
   }
-}
 ```
 
-With these settings, you will get completions for Tailwind CSS classes in HEEx template files. Examples:
+With these settings, you will get completions for Tailwind CSS classes in HEEx templates. Examples:
 
 ```heex
 <%!-- Standard class attribute --%>
@@ -170,3 +245,8 @@ With these settings, you will get completions for Tailwind CSS classes in HEEx t
   Content
 </div>
 ```
+
+## See also
+
+- [Erlang](./erlang.md)
+- [Gleam](./gleam.md)

docs/src/languages/tailwindcss.md πŸ”—

@@ -15,7 +15,7 @@ Languages which can be used with Tailwind CSS in Zed:
 - [CSS](./css.md)
 - [ERB](./ruby.md#using-the-tailwind-css-language-server-with-ruby)
 - [Gleam](./gleam.md)
-- [HEEx](./elixir.md#using-the-tailwind-css-language-server-with-heex)
+- [HEEx](./elixir.md#using-the-tailwind-css-language-server-with-heex-templates)
 - [HTML](./html.md#using-the-tailwind-css-language-server-with-html)
 - [TypeScript](./typescript.md#using-the-tailwind-css-language-server-with-typescript)
 - [JavaScript](./javascript.md#using-the-tailwind-css-language-server-with-javascript)

docs/src/migrate/webstorm.md πŸ”—

@@ -37,11 +37,11 @@ This opens the current directory in Zed.
 
 If you're coming from WebStorm, the fastest way to feel at home is to use the JetBrains keymap. During onboarding, you can select it as your base keymap. If you missed that step, you can change it anytime:
 
-1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows)
+1. Open Settings with {#kb zed::OpenSettings}
 2. Search for `Base Keymap`
 3. Select `JetBrains`
 
-This maps familiar shortcuts like `Shift Shift` for Search Everywhere, `Cmd+O` for Go to Class, and `Cmd+Shift+A` for Find Action.
+This maps familiar shortcuts like {#kb:jetbrains project_symbols::Toggle} for Go to Class and {#kb:jetbrains command_palette::Toggle} for Find Action.
 
 ## Set Up Editor Preferences
 
@@ -63,7 +63,7 @@ Zed also supports per-project settings. Create a `.zed/settings.json` file in yo
 
 ## Open or Create a Project
 
-After setup, press `Cmd+Shift+O` (with JetBrains keymap) to open a folder. This becomes your workspace in Zed. Unlike WebStorm, there's no project configuration wizard, no framework selection dialog, and no project structure setup required.
+After setup, use {#kb:jetbrains file_finder::Toggle} to open a folder. This becomes your workspace in Zed. Unlike WebStorm, there's no project configuration wizard, no framework selection dialog, and no project structure setup required.
 
 To start a new project, create a directory using your terminal or file manager, then open it in Zed. The editor will treat that folder as the root of your project. For new projects, you'd typically run `npm init`, `pnpm create`, or your framework's CLI tool first, then open the resulting folder in Zed.
 
@@ -72,60 +72,53 @@ You can also launch Zed from the terminal inside any folder with:
 
 Once inside a project:
 
-- Use `Cmd+Shift+O` or `Cmd+E` to jump between files quickly (like WebStorm's "Recent Files")
-- Use `Cmd+Shift+A` or `Shift Shift` to open the Command Palette (like WebStorm's "Search Everywhere")
-- Use `Cmd+O` to search for symbols (like WebStorm's "Go to Symbol")
+- Use {#kb:jetbrains file_finder::Toggle} to jump between files quickly (like WebStorm's "Recent Files")
+- Use {#kb:jetbrains command_palette::Toggle} to open the Command Palette (like WebStorm's "Search Everywhere")
+- Use {#kb:jetbrains project_symbols::Toggle} to search for symbols (like WebStorm's "Go to Symbol")
 
-Open buffers appear as tabs across the top. The Project Panel shows your file tree and Git status. Toggle it with `Cmd+1` (just like WebStorm's Project tool window).
+Open buffers appear as tabs across the top. The Project Panel shows your file tree and Git status. Toggle it with {#kb:jetbrains project_panel::ToggleFocus} (just like WebStorm's Project tool window).
 
 ## Differences in Keybindings
 
-If you chose the JetBrains keymap during onboarding, most of your shortcuts should already feel familiar. Here's a quick reference for how Zed compares to WebStorm.
-
-### Common Shared Keybindings
-
-| Action                        | Shortcut                |
-| ----------------------------- | ----------------------- |
-| Search Everywhere             | `Shift Shift`           |
-| Find Action / Command Palette | `Cmd + Shift + A`       |
-| Go to File                    | `Cmd + Shift + O`       |
-| Go to Symbol                  | `Cmd + O`               |
-| Recent Files                  | `Cmd + E`               |
-| Go to Definition              | `Cmd + B`               |
-| Find Usages                   | `Alt + F7`              |
-| Rename Symbol                 | `Shift + F6`            |
-| Reformat Code                 | `Cmd + Alt + L`         |
-| Toggle Project Panel          | `Cmd + 1`               |
-| Toggle Terminal               | `Alt + F12`             |
-| Duplicate Line                | `Cmd + D`               |
-| Delete Line                   | `Cmd + Backspace`       |
-| Move Line Up/Down             | `Shift + Alt + Up/Down` |
-| Expand/Shrink Selection       | `Alt + Up/Down`         |
-| Comment Line                  | `Cmd + /`               |
-| Go Back / Forward             | `Cmd + [` / `Cmd + ]`   |
-| Toggle Breakpoint             | `Ctrl + F8`             |
-
-### Different Keybindings (WebStorm β†’ Zed)
-
-| Action                 | WebStorm    | Zed (JetBrains keymap)   |
-| ---------------------- | ----------- | ------------------------ |
-| File Structure         | `Cmd + F12` | `Cmd + F12` (outline)    |
-| Navigate to Next Error | `F2`        | `F2`                     |
-| Run                    | `Ctrl + R`  | `Ctrl + Alt + R` (tasks) |
-| Debug                  | `Ctrl + D`  | `Alt + Shift + F9`       |
-| Stop                   | `Cmd + F2`  | `Ctrl + F2`              |
+If you chose the JetBrains keymap during onboarding, most of your shortcuts should already feel familiar. Here's a quick reference of common actions and their keybindings with the JetBrains keymap active.
+
+### Common Keybindings
+
+| Action                 | Zed Keybinding                                  |
+| ---------------------- | ----------------------------------------------- |
+| Command Palette        | {#kb:jetbrains command_palette::Toggle}         |
+| Go to File             | {#kb:jetbrains file_finder::Toggle}             |
+| Go to Symbol           | {#kb:jetbrains project_symbols::Toggle}         |
+| File Outline           | {#kb:jetbrains outline::Toggle}                 |
+| Go to Definition       | {#kb:jetbrains editor::GoToDefinition}          |
+| Find Usages            | {#kb:jetbrains editor::FindAllReferences}       |
+| Rename Symbol          | {#kb:jetbrains editor::Rename}                  |
+| Reformat Code          | {#kb:jetbrains editor::Format}                  |
+| Toggle Project Panel   | {#kb:jetbrains project_panel::ToggleFocus}      |
+| Toggle Terminal        | {#kb:jetbrains terminal_panel::Toggle}          |
+| Duplicate Line         | {#kb:jetbrains editor::DuplicateSelection}      |
+| Delete Line            | {#kb:jetbrains editor::DeleteLine}              |
+| Move Line Up           | {#kb:jetbrains editor::MoveLineUp}              |
+| Move Line Down         | {#kb:jetbrains editor::MoveLineDown}            |
+| Expand Selection       | {#kb:jetbrains editor::SelectLargerSyntaxNode}  |
+| Shrink Selection       | {#kb:jetbrains editor::SelectSmallerSyntaxNode} |
+| Comment Line           | {#kb:jetbrains editor::ToggleComments}          |
+| Go Back                | {#kb:jetbrains pane::GoBack}                    |
+| Go Forward             | {#kb:jetbrains pane::GoForward}                 |
+| Toggle Breakpoint      | {#kb:jetbrains editor::ToggleBreakpoint}        |
+| Navigate to Next Error | {#kb:jetbrains editor::GoToDiagnostic}          |
 
 ### Unique to Zed
 
-| Action            | Shortcut                   | Notes                          |
-| ----------------- | -------------------------- | ------------------------------ |
-| Toggle Right Dock | `Cmd + R`                  | Assistant panel, notifications |
-| Split Panes       | `Cmd + K`, then arrow keys | Create splits in any direction |
+| Action            | Keybinding                       | Notes                                                         |
+| ----------------- | -------------------------------- | ------------------------------------------------------------- |
+| Toggle Right Dock | {#kb workspace::ToggleRightDock} | Assistant panel, notifications                                |
+| Split Pane Right  | {#kb pane::SplitRight}           | Use other arrow keys to create splits in different directions |
 
 ### How to Customize Keybindings
 
-- Open the Command Palette (`Cmd+Shift+A` or `Shift Shift`)
-- Run `Zed: Open Keymap Editor`
+- Open the Command Palette ({#kb:jetbrains command_palette::Toggle})
+- Run `zed: open keymap`
 
 This opens a list of all available bindings. You can override individual shortcuts or remove conflicts.
 
@@ -143,9 +136,9 @@ WebStorm's index enables features like finding all usages across your entire cod
 
 **How to adapt:**
 
-- Search symbols across the project with `Cmd+O` (powered by the TypeScript language server)
-- Find files by name with `Cmd+Shift+O`
-- Use `Cmd+Shift+F` for text searchβ€”it stays fast even in large monorepos
+- Search symbols across the project with {#kb:jetbrains project_symbols::Toggle} (powered by the TypeScript language server)
+- Find files by name with {#kb:jetbrains file_finder::Toggle}
+- Use {#kb pane::DeploySearch} for text searchβ€”it stays fast even in large monorepos
 - Run `tsc --noEmit` or `eslint .` from the terminal when you need deeper project-wide analysis
 
 ### LSP vs. Native Language Intelligence
@@ -169,10 +162,10 @@ Where you might notice differences:
 
 **How to adapt:**
 
-- Use `Alt+Enter` for available code actionsβ€”the list will vary by language server
+- Use {#kb:jetbrains editor::ToggleCodeActions} for available code actionsβ€”the list will vary by language server
 - Ensure your `tsconfig.json` is properly configured so the language server understands your project structure
 - Use Prettier for consistent formatting (it's enabled by default for JS/TS)
-- For code inspection similar to WebStorm's "Inspect Code," check the Diagnostics panel (`Cmd+6`)β€”ESLint and TypeScript together catch many of the same issues
+- For code inspection similar to WebStorm's "Inspect Code," check the Diagnostics panel ({#kb:jetbrains diagnostics::Deploy})β€”ESLint and TypeScript together catch many of the same issues
 
 ### No Project Model
 
@@ -212,8 +205,8 @@ What this means in practice:
 ]
 ```
 
-- Use `Ctrl+Alt+R` to run tasks quickly
-- Lean on your terminal (`Alt+F12`) for anything tasks don't cover
+- Use {#kb:jetbrains task::Spawn} to run tasks quickly
+- Lean on your terminal ({#kb:jetbrains terminal_panel::Toggle}) for anything tasks don't cover
 
 ### No Framework Integration
 
@@ -223,8 +216,8 @@ Zed has none of this built-in. The TypeScript language server sees your code as
 
 **How to adapt:**
 
-- Use grep and file search liberally. `Cmd+Shift+F` with a regex can find component definitions, route configurations, or API endpoints.
-- Rely on your language server's "find references" (`Alt+F7`) for navigationβ€”it works, just without framework context
+- Use grep and file search liberally. {#kb pane::DeploySearch} with a regex can find component definitions, route configurations, or API endpoints.
+- Rely on your language server's "find references" ({#kb:jetbrains editor::FindAllReferences}) for navigationβ€”it works, just without framework context
 - Consider using framework-specific CLI tools (`ng`, `next`, `vite`) from Zed's terminal
 - For React, JSX/TSX syntax and TypeScript types still provide good intelligence
 
@@ -232,16 +225,16 @@ Zed has none of this built-in. The TypeScript language server sees your code as
 
 ### Tool Windows vs. Docks
 
-WebStorm organizes auxiliary views into numbered tool windows (Project = 1, npm = Alt+F11, Terminal = Alt+F12, etc.). Zed uses a similar concept called "docks":
+WebStorm organizes auxiliary views into numbered tool windows. Zed uses a similar concept called "docks":
 
-| WebStorm Tool Window | Zed Equivalent | Shortcut (JetBrains keymap) |
-| -------------------- | -------------- | --------------------------- |
-| Project (1)          | Project Panel  | `Cmd + 1`                   |
-| Git (9 or Cmd+0)     | Git Panel      | `Cmd + 0`                   |
-| Terminal (Alt+F12)   | Terminal Panel | `Alt + F12`                 |
-| Structure (7)        | Outline Panel  | `Cmd + 7`                   |
-| Problems (6)         | Diagnostics    | `Cmd + 6`                   |
-| Debug (5)            | Debug Panel    | `Cmd + 5`                   |
+| WebStorm Tool Window | Zed Equivalent | Zed Keybinding                             |
+| -------------------- | -------------- | ------------------------------------------ |
+| Project              | Project Panel  | {#kb:jetbrains project_panel::ToggleFocus} |
+| Git                  | Git Panel      | {#kb:jetbrains git_panel::ToggleFocus}     |
+| Terminal             | Terminal Panel | {#kb:jetbrains terminal_panel::Toggle}     |
+| Structure            | Outline Panel  | {#kb:jetbrains outline_panel::ToggleFocus} |
+| Problems             | Diagnostics    | {#kb:jetbrains diagnostics::Deploy}        |
+| Debug                | Debug Panel    | {#kb:jetbrains debug_panel::ToggleFocus}   |
 
 Zed has three dock positions: left, bottom, and right. Panels can be moved between docks by dragging or through settings.
 
@@ -252,10 +245,10 @@ Note that there's no dedicated npm tool window in Zed. Use the terminal or defin
 Both WebStorm and Zed offer integrated debugging for JavaScript and TypeScript:
 
 - Zed uses `vscode-js-debug` (the same debug adapter that VS Code uses)
-- Set breakpoints with `Ctrl+F8`
-- Start debugging with `Alt+Shift+F9` or press `F4` and select a debug target
-- Step through code with `F7` (step into), `F8` (step over), `Shift+F8` (step out)
-- Continue execution with `F9`
+- Set breakpoints with {#kb:jetbrains editor::ToggleBreakpoint}
+- Start debugging with {#kb:jetbrains debugger::Start}
+- Step through code with {#kb:jetbrains debugger::StepInto} (step into), {#kb:jetbrains debugger::StepOver} (step over), {#kb:jetbrains debugger::StepOut} (step out)
+- Continue execution with {#kb:jetbrains debugger::Continue}
 
 Zed can debug:
 
@@ -359,7 +352,7 @@ If you're used to AI assistants in WebStorm (like GitHub Copilot, JetBrains AI A
 
 ### Configuring GitHub Copilot
 
-1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows)
+1. Open Settings with {#kb zed::OpenSettings}
 2. Navigate to **AI β†’ Edit Predictions**
 3. Click **Configure** next to "Configure Providers"
 4. Under **GitHub Copilot**, click **Sign in to GitHub**

docs/src/performance.md πŸ”—

@@ -15,7 +15,7 @@ See [samply](https://github.com/mstange/samply)'s README on how to install and r
 
 The profile.json does not contain any symbols. Firefox profiler can add the local symbols to the profile for for. To do that hit the upload local profile button in the top right corner.
 
-<img width="851" height="613" alt="image" src="https://github.com/user-attachments/assets/cbef2b51-0442-4ee9-bc5c-95f6ccf9be2c" />
+<img width="851" height="auto" alt="image" src="https://github.com/user-attachments/assets/cbef2b51-0442-4ee9-bc5c-95f6ccf9be2c" />
 
 # In depth CPU profiling (Tracing)
 
@@ -52,10 +52,12 @@ Download the profiler:
 ## Usage
 
 Open the profiler (tracy-profiler), you should see zed in the list of `Discovered clients` click it.
-<img width="392" height="287" alt="image" src="https://github.com/user-attachments/assets/b6f06fc3-6b25-41c7-ade9-558cc93d6033" />
+
+<img width="392" height="auto" alt="image" src="https://github.com/user-attachments/assets/b6f06fc3-6b25-41c7-ade9-558cc93d6033" />
 
 To find functions that take a long time follow this image:
-<img width="888" height="1159" alt="image" src="https://github.com/user-attachments/assets/77087617-f53a-4331-863d-e59f8a5b6f0b" />
+
+<img width="888" height="auto" alt="image" src="https://github.com/user-attachments/assets/77087617-f53a-4331-863d-e59f8a5b6f0b" />
 
 # Task/Async profiling
 

docs/src/reference/all-settings.md πŸ”—

@@ -3462,12 +3462,6 @@ Non-negative `integer` values
 - Setting: `regex`
 - Default: `false`
 
-### Search On Input
-
-- Description: Whether to search on input in project search.
-- Setting: `search_on_input`
-- Default: `true`
-
 ### Center On Match
 
 - Description: Whether to center the cursor on each search match when navigating.

tooling/xtask/src/tasks/workflows/compare_perf.rs πŸ”—

@@ -42,7 +42,11 @@ pub fn run_perf(
     }
 
     fn install_hyperfine() -> Step<Use> {
-        named::uses("taiki-e", "install-action", "hyperfine")
+        named::uses(
+            "taiki-e",
+            "install-action",
+            "b4f2d5cb8597b15997c8ede873eb6185efc5f0ad", // hyperfine
+        )
     }
 
     fn compare_runs(head: &WorkflowInput, base: &WorkflowInput) -> Step<Run> {

tooling/xtask/src/tasks/workflows/extension_bump.rs πŸ”—

@@ -5,8 +5,9 @@ use crate::tasks::workflows::{
     extension_tests::{self},
     runners,
     steps::{
-        self, BASH_SHELL, CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, FluentBuilder,
-        NamedJob, cache_rust_dependencies_namespace, checkout_repo, dependant_job, named,
+        self, BASH_SHELL, CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob,
+        RepositoryTarget, cache_rust_dependencies_namespace, checkout_repo, dependant_job,
+        generate_token, named,
     },
     vars::{
         JobOutput, StepOutput, WorkflowInput, WorkflowSecret,
@@ -123,7 +124,7 @@ fn create_version_label(
     app_secret: &WorkflowSecret,
 ) -> (NamedJob, StepOutput) {
     let (generate_token, generated_token) =
-        generate_token(&app_id.to_string(), &app_secret.to_string(), None);
+        generate_token(&app_id.to_string(), &app_secret.to_string()).into();
     let (determine_tag_step, tag) = determine_tag(current_version);
     let job = steps::dependant_job(dependencies)
         .defaults(extension_job_defaults())
@@ -144,7 +145,12 @@ fn create_version_label(
 }
 
 fn create_version_tag(tag: &StepOutput, generated_token: StepOutput) -> Step<Use> {
-    named::uses("actions", "github-script", "v7").with(
+    named::uses(
+        "actions",
+        "github-script",
+        "f28e40c7f34bde8b3046d885e986cb6290c5673b", // v7
+    )
+    .with(
         Input::default()
             .add(
                 "script",
@@ -221,7 +227,7 @@ fn bump_extension_version(
     app_secret: &WorkflowSecret,
 ) -> NamedJob {
     let (generate_token, generated_token) =
-        generate_token(&app_id.to_string(), &app_secret.to_string(), None);
+        generate_token(&app_id.to_string(), &app_secret.to_string()).into();
     let (bump_version, _new_version, title, body, branch_name) =
         bump_version(current_version, bump_type);
 
@@ -249,49 +255,6 @@ fn bump_extension_version(
     named::job(job)
 }
 
-pub(crate) fn generate_token(
-    app_id_source: &str,
-    app_secret_source: &str,
-    repository_target: Option<RepositoryTarget>,
-) -> (Step<Use>, StepOutput) {
-    let step = named::uses("actions", "create-github-app-token", "v2")
-        .id("generate-token")
-        .add_with(
-            Input::default()
-                .add("app-id", app_id_source)
-                .add("private-key", app_secret_source)
-                .when_some(
-                    repository_target,
-                    |input,
-                     RepositoryTarget {
-                         owner,
-                         repositories,
-                         permissions,
-                     }| {
-                        input
-                            .when_some(owner, |input, owner| input.add("owner", owner))
-                            .when_some(repositories, |input, repositories| {
-                                input.add("repositories", repositories)
-                            })
-                            .when_some(permissions, |input, permissions| {
-                                permissions
-                                    .into_iter()
-                                    .fold(input, |input, (permission, level)| {
-                                        input.add(
-                                            permission,
-                                            serde_json::to_value(&level).unwrap_or_default(),
-                                        )
-                                    })
-                            })
-                    },
-                ),
-        );
-
-    let generated_token = StepOutput::new(&step, "token");
-
-    (step, generated_token)
-}
-
 fn install_bump_2_version() -> Step<Run> {
     named::run(
         runners::Platform::Linux,
@@ -364,7 +327,12 @@ fn create_pull_request(
     generated_token: StepOutput,
     branch_name: StepOutput,
 ) -> Step<Use> {
-    named::uses("peter-evans", "create-pull-request", "v7").with(
+    named::uses(
+        "peter-evans",
+        "create-pull-request",
+        "98357b18bf14b5342f975ff684046ec3b2a07725",
+    )
+    .with(
         Input::default()
             .add("title", title.to_string())
             .add("body", body.to_string())
@@ -389,11 +357,9 @@ fn trigger_release(
     app_secret: &WorkflowSecret,
 ) -> NamedJob {
     let extension_registry = RepositoryTarget::new("zed-industries", &["extensions"]);
-    let (generate_token, generated_token) = generate_token(
-        &app_id.to_string(),
-        &app_secret.to_string(),
-        Some(extension_registry),
-    );
+    let (generate_token, generated_token) =
+        generate_token(&app_id.to_string(), &app_secret.to_string())
+            .for_repository(extension_registry);
     let (get_extension_id, extension_id) = get_extension_id();
     let (release_action, pull_request_number) = release_action(extension_id, tag, &generated_token);
 
@@ -452,7 +418,11 @@ fn enable_automerge_if_staff(
     pull_request_number: StepOutput,
     generated_token: StepOutput,
 ) -> Step<Use> {
-    named::uses("actions", "github-script", "v7")
+    named::uses(
+        "actions",
+        "github-script",
+        "f28e40c7f34bde8b3046d885e986cb6290c5673b", // v7
+    )
         .add_with(("github-token", generated_token.to_string()))
         .add_with((
             "script",
@@ -526,34 +496,3 @@ fn extension_workflow_secrets() -> (WorkflowSecret, WorkflowSecret) {
 
     (app_id, app_secret)
 }
-
-pub(crate) struct RepositoryTarget {
-    owner: Option<String>,
-    repositories: Option<String>,
-    permissions: Option<Vec<(String, Level)>>,
-}
-
-impl RepositoryTarget {
-    pub fn new<T: ToString>(owner: T, repositories: &[&str]) -> Self {
-        Self {
-            owner: Some(owner.to_string()),
-            repositories: Some(repositories.join("\n")),
-            permissions: None,
-        }
-    }
-
-    pub fn current() -> Self {
-        Self {
-            owner: None,
-            repositories: None,
-            permissions: None,
-        }
-    }
-
-    pub fn permissions(self, permissions: impl Into<Vec<(String, Level)>>) -> Self {
-        Self {
-            permissions: Some(permissions.into()),
-            ..self
-        }
-    }
-}

tooling/xtask/src/tasks/workflows/extension_tests.rs πŸ”—

@@ -12,7 +12,7 @@ use crate::tasks::workflows::{
     vars::{PathCondition, StepOutput, WorkflowInput, one_workflow_per_non_main_branch_and_token},
 };
 
-pub(crate) const ZED_EXTENSION_CLI_SHA: &str = "03d8e9aee95ea6117d75a48bcac2e19241f6e667";
+pub(crate) const ZED_EXTENSION_CLI_SHA: &str = "1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7";
 
 // This should follow the set target in crates/extension/src/extension_builder.rs
 const EXTENSION_RUST_TARGET: &str = "wasm32-wasip2";

tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs πŸ”—

@@ -9,9 +9,10 @@ use crate::tasks::workflows::steps::CheckoutStep;
 use crate::tasks::workflows::steps::cache_rust_dependencies_namespace;
 use crate::tasks::workflows::vars::JobOutput;
 use crate::tasks::workflows::{
-    extension_bump::{RepositoryTarget, generate_token},
     runners,
-    steps::{self, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob, named},
+    steps::{
+        self, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob, RepositoryTarget, generate_token, named,
+    },
     vars::{self, StepOutput, WorkflowInput},
 };
 
@@ -49,7 +50,7 @@ pub(crate) fn extension_workflow_rollout() -> Workflow {
 
 fn fetch_extension_repos(filter_repos_input: &WorkflowInput) -> (NamedJob, JobOutput, JobOutput) {
     fn get_repositories(filter_repos_input: &WorkflowInput) -> (Step<Use>, StepOutput) {
-        let step = named::uses("actions", "github-script", "v7")
+        let step = named::uses("actions", "github-script", "f28e40c7f34bde8b3046d885e986cb6290c5673b")
             .id("list-repos")
             .add_with((
                 "script",
@@ -268,25 +269,29 @@ fn rollout_workflows_to_extension(
         "#,
         };
 
-        named::uses("peter-evans", "create-pull-request", "v7")
-            .add_with(("path", "extension"))
-            .add_with(("title", title.clone()))
-            .add_with(("body", body))
-            .add_with(("commit-message", title))
-            .add_with(("branch", "update-workflows"))
-            .add_with((
-                "committer",
-                "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
-            ))
-            .add_with((
-                "author",
-                "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
-            ))
-            .add_with(("base", "main"))
-            .add_with(("delete-branch", true))
-            .add_with(("token", token.to_string()))
-            .add_with(("sign-commits", true))
-            .id("create-pr")
+        named::uses(
+            "peter-evans",
+            "create-pull-request",
+            "98357b18bf14b5342f975ff684046ec3b2a07725",
+        )
+        .add_with(("path", "extension"))
+        .add_with(("title", title.clone()))
+        .add_with(("body", body))
+        .add_with(("commit-message", title))
+        .add_with(("branch", "update-workflows"))
+        .add_with((
+            "committer",
+            "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
+        ))
+        .add_with((
+            "author",
+            "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
+        ))
+        .add_with(("base", "main"))
+        .add_with(("delete-branch", true))
+        .add_with(("token", token.to_string()))
+        .add_with(("sign-commits", true))
+        .id("create-pr")
     }
 
     fn enable_auto_merge(token: &StepOutput) -> Step<gh_workflow::Run> {
@@ -303,17 +308,15 @@ fn rollout_workflows_to_extension(
         ))
     }
 
-    let (authenticate, token) = generate_token(
-        vars::ZED_ZIPPY_APP_ID,
-        vars::ZED_ZIPPY_APP_PRIVATE_KEY,
-        Some(
+    let (authenticate, token) =
+        generate_token(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY).for_repository(
             RepositoryTarget::new("zed-extensions", &["${{ matrix.repo }}"]).permissions([
                 ("permission-pull-requests".to_owned(), Level::Write),
                 ("permission-contents".to_owned(), Level::Write),
                 ("permission-workflows".to_owned(), Level::Write),
             ]),
-        ),
-    );
+        );
+
     let (calculate_short_sha, short_sha) = get_short_sha();
 
     let job = Job::default()
@@ -368,14 +371,11 @@ fn create_rollout_tag(rollout_job: &NamedJob, filter_repos_input: &WorkflowInput
         "#})
     }
 
-    let (authenticate, token) = generate_token(
-        vars::ZED_ZIPPY_APP_ID,
-        vars::ZED_ZIPPY_APP_PRIVATE_KEY,
-        Some(
+    let (authenticate, token) =
+        generate_token(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY).for_repository(
             RepositoryTarget::current()
                 .permissions([("permission-contents".to_owned(), Level::Write)]),
-        ),
-    );
+        );
 
     let job = Job::default()
         .needs([rollout_job.name.clone()])

tooling/xtask/src/tasks/workflows/publish_extension_cli.rs πŸ”—

@@ -2,9 +2,8 @@ use gh_workflow::{ctx::Context, *};
 use indoc::indoc;
 
 use crate::tasks::workflows::{
-    extension_bump::{RepositoryTarget, generate_token},
     runners,
-    steps::{self, CommonJobConditions, NamedJob, named},
+    steps::{self, CommonJobConditions, NamedJob, RepositoryTarget, generate_token, named},
     vars::{self, StepOutput},
 };
 
@@ -42,7 +41,7 @@ fn publish_job() -> NamedJob {
     named::job(
         Job::default()
             .with_repository_owner_guard()
-            .runs_on(runners::LINUX_SMALL)
+            .runs_on(runners::LINUX_DEFAULT)
             .add_step(steps::checkout_repo())
             .add_step(steps::cache_rust_dependencies_namespace())
             .add_step(steps::setup_linux())
@@ -52,11 +51,8 @@ fn publish_job() -> NamedJob {
 }
 
 fn update_sha_in_zed(publish_job: &NamedJob) -> NamedJob {
-    let (generate_token, generated_token) = generate_token(
-        vars::ZED_ZIPPY_APP_ID,
-        vars::ZED_ZIPPY_APP_PRIVATE_KEY,
-        Some(RepositoryTarget::current()),
-    );
+    let (generate_token, generated_token) =
+        generate_token(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY).into();
 
     fn replace_sha() -> Step<Run> {
         named::bash(indoc! {r#"
@@ -92,7 +88,7 @@ fn create_pull_request_zed(generated_token: &StepOutput, short_sha: &StepOutput)
         short_sha
     );
 
-    named::uses("peter-evans", "create-pull-request", "v7").with(
+    named::uses("peter-evans", "create-pull-request", "98357b18bf14b5342f975ff684046ec3b2a07725").with(
         Input::default()
             .add("title", title.clone())
             .add(
@@ -121,11 +117,9 @@ fn create_pull_request_zed(generated_token: &StepOutput, short_sha: &StepOutput)
 
 fn update_sha_in_extensions(publish_job: &NamedJob) -> NamedJob {
     let extensions_repo = RepositoryTarget::new("zed-industries", &["extensions"]);
-    let (generate_token, generated_token) = generate_token(
-        vars::ZED_ZIPPY_APP_ID,
-        vars::ZED_ZIPPY_APP_PRIVATE_KEY,
-        Some(extensions_repo),
-    );
+    let (generate_token, generated_token) =
+        generate_token(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY)
+            .for_repository(extensions_repo);
 
     fn checkout_extensions_repo(token: &StepOutput) -> Step<Use> {
         named::uses(
@@ -165,7 +159,7 @@ fn create_pull_request_extensions(
 ) -> Step<Use> {
     let title = format!("Bump extension CLI version to `{}`", short_sha);
 
-    named::uses("peter-evans", "create-pull-request", "v7").with(
+    named::uses("peter-evans", "create-pull-request", "98357b18bf14b5342f975ff684046ec3b2a07725").with(
         Input::default()
             .add("title", title.clone())
             .add(

tooling/xtask/src/tasks/workflows/steps.rs πŸ”—

@@ -1,7 +1,11 @@
 use gh_workflow::*;
 use serde_json::Value;
 
-use crate::tasks::workflows::{runners::Platform, vars, vars::StepOutput};
+use crate::tasks::workflows::{
+    runners::Platform,
+    steps::named::function_name,
+    vars::{self, StepOutput},
+};
 
 pub(crate) fn use_clang(job: Job) -> Job {
     job.add_env(Env::new("CC", "clang"))
@@ -114,7 +118,7 @@ impl From<CheckoutStep> for Step<Use> {
             .uses(
                 "actions",
                 "checkout",
-                "11bd71901bbe5b1630ceea73d27597364c9af683", // v4
+                "93cb6efe18208431cddfb8368fd83d5badbf9bfd", // v5.0.1
             )
             // prevent checkout action from running `git clean -ffdx` which
             // would delete the target directory
@@ -173,7 +177,11 @@ pub fn cargo_fmt() -> Step<Run> {
 }
 
 pub fn cargo_install_nextest() -> Step<Use> {
-    named::uses("taiki-e", "install-action", "nextest")
+    named::uses(
+        "taiki-e",
+        "install-action",
+        "921e2c9f7148d7ba14cd819f417db338f63e733c", // nextest
+    )
 }
 
 pub fn setup_cargo_config(platform: Platform) -> Step<Run> {
@@ -226,9 +234,13 @@ pub fn install_rustup_target(target: &str) -> Step<Run> {
 }
 
 pub fn cache_rust_dependencies_namespace() -> Step<Use> {
-    named::uses("namespacelabs", "nscloud-cache-action", "v1")
-        .add_with(("cache", "rust"))
-        .add_with(("path", "~/.rustup"))
+    named::uses(
+        "namespacelabs",
+        "nscloud-cache-action",
+        "a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // v1
+    )
+    .add_with(("cache", "rust"))
+    .add_with(("path", "~/.rustup"))
 }
 
 pub fn setup_sccache(platform: Platform) -> Step<Run> {
@@ -255,14 +267,24 @@ pub fn show_sccache_stats(platform: Platform) -> Step<Run> {
 }
 
 pub fn cache_nix_dependencies_namespace() -> Step<Use> {
-    named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("cache", "nix"))
+    named::uses(
+        "namespacelabs",
+        "nscloud-cache-action",
+        "a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // v1
+    )
+    .add_with(("cache", "nix"))
 }
 
 pub fn cache_nix_store_macos() -> Step<Use> {
     // On macOS, `/nix` is on a read-only root filesystem so nscloud's `cache: nix`
     // cannot mount or symlink there. Instead we cache a user-writable directory and
     // use nix-store --import/--export in separate steps to transfer store paths.
-    named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("path", "~/nix-cache"))
+    named::uses(
+        "namespacelabs",
+        "nscloud-cache-action",
+        "a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // v1
+    )
+    .add_with(("path", "~/nix-cache"))
 }
 
 pub fn setup_linux() -> Step<Run> {
@@ -491,15 +513,119 @@ pub fn git_checkout(ref_name: &dyn std::fmt::Display) -> Step<Run> {
         .add_env(("REF_NAME", ref_name.to_string()))
 }
 
+pub(crate) struct GenerateAppToken<'a> {
+    job_name: String,
+    app_id: &'a str,
+    app_secret: &'a str,
+    repository_target: Option<RepositoryTarget>,
+}
+
+impl<'a> GenerateAppToken<'a> {
+    pub fn for_repository(self, repository_target: RepositoryTarget) -> (Step<Use>, StepOutput) {
+        Self {
+            repository_target: Some(repository_target),
+            ..self
+        }
+        .into()
+    }
+}
+
+impl<'a> From<GenerateAppToken<'a>> for (Step<Use>, StepOutput) {
+    fn from(token: GenerateAppToken<'a>) -> Self {
+        let step = Step::new(token.job_name)
+            .uses(
+                "actions",
+                "create-github-app-token",
+                "f8d387b68d61c58ab83c6c016672934102569859",
+            )
+            .id("generate-token")
+            .add_with(
+                Input::default()
+                    .add("app-id", token.app_id)
+                    .add("private-key", token.app_secret)
+                    .when_some(
+                        token.repository_target,
+                        |input,
+                         RepositoryTarget {
+                             owner,
+                             repositories,
+                             permissions,
+                         }| {
+                            input
+                                .when_some(owner, |input, owner| input.add("owner", owner))
+                                .when_some(repositories, |input, repositories| {
+                                    input.add("repositories", repositories)
+                                })
+                                .when_some(permissions, |input, permissions| {
+                                    permissions.into_iter().fold(
+                                        input,
+                                        |input, (permission, level)| {
+                                            input.add(
+                                                permission,
+                                                serde_json::to_value(&level).unwrap_or_default(),
+                                            )
+                                        },
+                                    )
+                                })
+                        },
+                    ),
+            );
+
+        let generated_token = StepOutput::new(&step, "token");
+        (step, generated_token)
+    }
+}
+
+pub(crate) struct RepositoryTarget {
+    owner: Option<String>,
+    repositories: Option<String>,
+    permissions: Option<Vec<(String, Level)>>,
+}
+
+impl RepositoryTarget {
+    pub fn new<T: ToString>(owner: T, repositories: &[&str]) -> Self {
+        Self {
+            owner: Some(owner.to_string()),
+            repositories: Some(repositories.join("\n")),
+            permissions: None,
+        }
+    }
+
+    pub fn current() -> Self {
+        Self {
+            owner: None,
+            repositories: None,
+            permissions: None,
+        }
+    }
+
+    pub fn permissions(self, permissions: impl Into<Vec<(String, Level)>>) -> Self {
+        Self {
+            permissions: Some(permissions.into()),
+            ..self
+        }
+    }
+}
+
+pub(crate) fn generate_token<'a>(
+    app_id_source: &'a str,
+    app_secret_source: &'a str,
+) -> GenerateAppToken<'a> {
+    generate_token_with_job_name(app_id_source, app_secret_source)
+}
+
 pub fn authenticate_as_zippy() -> (Step<Use>, StepOutput) {
-    let step = named::uses(
-        "actions",
-        "create-github-app-token",
-        "bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1",
-    )
-    .add_with(("app-id", vars::ZED_ZIPPY_APP_ID))
-    .add_with(("private-key", vars::ZED_ZIPPY_APP_PRIVATE_KEY))
-    .id("get-app-token");
-    let output = StepOutput::new(&step, "token");
-    (step, output)
+    generate_token_with_job_name(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY).into()
+}
+
+fn generate_token_with_job_name<'a>(
+    app_id_source: &'a str,
+    app_secret_source: &'a str,
+) -> GenerateAppToken<'a> {
+    GenerateAppToken {
+        job_name: function_name(1),
+        app_id: app_id_source,
+        app_secret: app_secret_source,
+        repository_target: None,
+    }
 }