From 0bcf71f786ccf2194901ce1542fef7d9b4e2e82e Mon Sep 17 00:00:00 2001 From: Juan Pablo Briones Date: Thu, 7 May 2026 00:25:42 -0400 Subject: [PATCH 01/33] vim: Add C preprocessor check in matching function (#55515) Closes #24820 This PR fixes the bug specified in issue https://github.com/zed-industries/zed/issues/24820, now the matching function checks if the cursor is above a comment or a directive before defaulting to a bracket range as neovim does. It also fixes fixes the `line_end` calculations so that when `%` is pressed inside a bracket range https://github.com/user-attachments/assets/f59daa6f-9769-45e8-bb8c-2d533470b59d Release Notes: - `fn matching()` checks for `preprocessor directives` or `comments` before defaulting to any bracket range. - In `fn matching()`line_end calculations avoid expanding a blank current line into start..EOF. --- crates/vim/src/motion.rs | 137 +++++++++++------- .../vim/test_data/test_matching_comments.json | 3 + ...test_matching_preprocessor_directives.json | 14 +- 3 files changed, 96 insertions(+), 58 deletions(-) diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 6e992704f54bf7..28669d4890a2e7 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -2452,7 +2452,7 @@ fn find_matching_bracket_text_based( .find_map(|(ch, char_offset)| get_bracket_pair(ch).map(|info| (info, char_offset))); if bracket_info.is_none() { - return find_matching_c_preprocessor_directive(map, line_range); + return find_matching_c_preprocessor_directive(map, line_range, offset); } let (open, close, is_opening) = bracket_info?.0; @@ -2489,18 +2489,20 @@ fn find_matching_bracket_text_based( fn find_matching_c_preprocessor_directive( map: &DisplaySnapshot, line_range: Range, + offset: MultiBufferOffset, ) -> Option { let line_start = map .buffer_chars_at(line_range.start) .skip_while(|(c, _)| *c == ' ' || *c == '\t') + .take_while(|(c, char_offset)| *char_offset < line_range.end && !c.is_whitespace()) .map(|(c, _)| c) - .take(6) .collect::(); - if line_start.starts_with("#if") - || line_start.starts_with("#else") - || line_start.starts_with("#elif") - { + if line_range.start + line_start.len() < offset { + return None; + } + + if line_start.starts_with("#if") || line_start.starts_with("#el") { let mut depth = 0i32; for (ch, char_offset) in map.buffer_chars_at(line_range.end) { if ch != '\n' { @@ -2618,8 +2620,30 @@ fn matching( // Ensure the range is contained by the current line. let mut line_end = map.next_line_boundary(point).0; - if line_end == point { - line_end = map.max_point().to_point(map); + let max_point = map.max_point().to_point(map); + + // Only widen to EOF when the cursor is actually at EOF. + // This avoids expanding a blank current line into start..EOF. + if line_end == point && point == max_point { + line_end = max_point; + } + + let line_range = map.prev_line_boundary(point).0..line_end; + let line_range = line_range.start.to_offset(&map.buffer_snapshot()) + ..line_range.end.to_offset(&map.buffer_snapshot()); + + if let Some(preproc_range) = find_matching_c_preprocessor_directive(map, line_range, offset) { + return preproc_range.to_display_point(map); + } + + if let Some((open_range, close_range)) = comment_delimiter_pair(map, offset) { + if open_range.contains(&offset) { + return close_range.start.to_display_point(map); + } + + if close_range.contains(&offset) { + return open_range.start.to_display_point(map); + } } let is_quote_char = |ch: char| matches!(ch, '\'' | '"' | '`'); @@ -2729,32 +2753,6 @@ fn matching( continue; } - if let Some((open_range, close_range)) = comment_delimiter_pair(map, offset) { - if open_range.contains(&offset) { - return close_range.start.to_display_point(map); - } - - if close_range.contains(&offset) { - return open_range.start.to_display_point(map); - } - - let open_candidate = (open_range.start >= offset - && line_range.contains(&open_range.start)) - .then_some((open_range.start.saturating_sub(offset), close_range.start)); - - let close_candidate = (close_range.start >= offset - && line_range.contains(&close_range.start)) - .then_some((close_range.start.saturating_sub(offset), open_range.start)); - - if let Some((_, destination)) = [open_candidate, close_candidate] - .into_iter() - .flatten() - .min_by_key(|(distance, _)| *distance) - { - return destination.to_display_point(map); - } - } - closest_pair_destination .map(|destination| destination.to_display_point(map)) .unwrap_or_else(|| { @@ -3663,6 +3661,10 @@ mod test { cx.shared_state().await.assert_eq(indoc! {r"/* this is a comment ˇ*/"}); + cx.simulate_shared_keystrokes("k %").await; + cx.shared_state().await.assert_eq(indoc! {r"/* + ˇ this is a comment + */"}); cx.set_shared_state("ˇ// comment").await; cx.simulate_shared_keystrokes("%").await; @@ -3673,48 +3675,53 @@ mod test { async fn test_matching_preprocessor_directives(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; - cx.set_shared_state(indoc! {r"#ˇif + cx.set_shared_state(indoc! {r" + #ˇif - #else + #else - #endif - "}) + #endif + "}) .await; cx.simulate_shared_keystrokes("%").await; - cx.shared_state().await.assert_eq(indoc! {r"#if + cx.shared_state().await.assert_eq(indoc! {r" + #if ˇ#else #endif - "}); + "}); cx.simulate_shared_keystrokes("%").await; - cx.shared_state().await.assert_eq(indoc! {r"#if + cx.shared_state().await.assert_eq(indoc! {r" + #if #else ˇ#endif - "}); + "}); cx.simulate_shared_keystrokes("%").await; - cx.shared_state().await.assert_eq(indoc! {r"ˇ#if + cx.shared_state().await.assert_eq(indoc! {r" + ˇ#if #else #endif - "}); + "}); cx.set_shared_state(indoc! {r" - #ˇif - #if - - #else - - #endif + #ˇif + #if #else + #endif - "}) + + #else + + #endif + "}) .await; cx.simulate_shared_keystrokes("%").await; @@ -3727,8 +3734,9 @@ mod test { #endif ˇ#else + #endif - "}); + "}); cx.simulate_shared_keystrokes("% %").await; cx.shared_state().await.assert_eq(indoc! {r" @@ -3740,8 +3748,9 @@ mod test { #endif #else + #endif - "}); + "}); cx.simulate_shared_keystrokes("j % % %").await; cx.shared_state().await.assert_eq(indoc! {r" #if @@ -3752,8 +3761,28 @@ mod test { #endif #else + #endif - "}); + "}); + + cx.set_shared_state(indoc! {r" + #if definedˇ(something) + + #endif + "}) + .await; + cx.simulate_shared_keystrokes("%").await; + cx.shared_state().await.assert_eq(indoc! {r" + #if defined(somethingˇ) + + #endif + "}); + cx.simulate_shared_keystrokes("0 %").await; + cx.shared_state().await.assert_eq(indoc! {r" + #if defined(something) + + ˇ#endif + "}); } #[gpui::test] diff --git a/crates/vim/test_data/test_matching_comments.json b/crates/vim/test_data/test_matching_comments.json index 7fcf5e46e1ea16..8d130621913356 100644 --- a/crates/vim/test_data/test_matching_comments.json +++ b/crates/vim/test_data/test_matching_comments.json @@ -5,6 +5,9 @@ {"Get":{"state":"ˇ/*\n this is a comment\n*/","mode":"Normal"}} {"Key":"%"} {"Get":{"state":"/*\n this is a comment\nˇ*/","mode":"Normal"}} +{"Key":"k"} +{"Key":"%"} +{"Get":{"state":"/*\nˇ this is a comment\n*/","mode":"Normal"}} {"Put":{"state":"ˇ// comment"}} {"Key":"%"} {"Get":{"state":"ˇ// comment","mode":"Normal"}} diff --git a/crates/vim/test_data/test_matching_preprocessor_directives.json b/crates/vim/test_data/test_matching_preprocessor_directives.json index 9f0bd9792ee8da..7a55ac7995f5bd 100644 --- a/crates/vim/test_data/test_matching_preprocessor_directives.json +++ b/crates/vim/test_data/test_matching_preprocessor_directives.json @@ -5,14 +5,20 @@ {"Get":{"state":"#if\n\n#else\n\nˇ#endif\n","mode":"Normal"}} {"Key":"%"} {"Get":{"state":"ˇ#if\n\n#else\n\n#endif\n","mode":"Normal"}} -{"Put":{"state":"#ˇif\n #if\n\n #else\n\n #endif\n\n#else\n#endif\n"}} +{"Put":{"state":"#ˇif\n #if\n\n #else\n\n #endif\n\n#else\n\n#endif\n"}} {"Key":"%"} -{"Get":{"state":"#if\n #if\n\n #else\n\n #endif\n\nˇ#else\n#endif\n","mode":"Normal"}} +{"Get":{"state":"#if\n #if\n\n #else\n\n #endif\n\nˇ#else\n\n#endif\n","mode":"Normal"}} {"Key":"%"} {"Key":"%"} -{"Get":{"state":"ˇ#if\n #if\n\n #else\n\n #endif\n\n#else\n#endif\n","mode":"Normal"}} +{"Get":{"state":"ˇ#if\n #if\n\n #else\n\n #endif\n\n#else\n\n#endif\n","mode":"Normal"}} {"Key":"j"} {"Key":"%"} {"Key":"%"} {"Key":"%"} -{"Get":{"state":"#if\n ˇ#if\n\n #else\n\n #endif\n\n#else\n#endif\n","mode":"Normal"}} +{"Get":{"state":"#if\n ˇ#if\n\n #else\n\n #endif\n\n#else\n\n#endif\n","mode":"Normal"}} +{"Put":{"state":"#if definedˇ(something)\n\n#endif\n"}} +{"Key":"%"} +{"Get":{"state":"#if defined(somethingˇ)\n\n#endif\n","mode":"Normal"}} +{"Key":"0"} +{"Key":"%"} +{"Get":{"state":"#if defined(something)\n\nˇ#endif\n","mode":"Normal"}} From 6aa90e750ded06c684393ab4cccf159c731f58b6 Mon Sep 17 00:00:00 2001 From: Xin Zhao Date: Thu, 7 May 2026 14:55:11 +0800 Subject: [PATCH 02/33] docs: Update actions format (#54869) Self-Review Checklist: - [ ] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Change the actions in docs to adopt the right format. Release Notes: - N/A --- docs/.conventions/CONVENTIONS.md | 4 ++-- docs/.doc-examples/configuration.md | 4 ++-- docs/AGENTS.md | 8 +++---- docs/README.md | 4 ++-- docs/src/ai/agent-panel.md | 4 ++-- docs/src/ai/agent-settings.md | 2 +- docs/src/ai/external-agents.md | 14 ++++++------ docs/src/ai/llm-providers.md | 32 ++++++++++++++-------------- docs/src/ai/mcp.md | 4 ++-- docs/src/appearance.md | 4 ++-- docs/src/authentication.md | 4 ++-- docs/src/collaboration/channels.md | 2 +- docs/src/command-palette.md | 2 +- docs/src/configuring-languages.md | 18 ++++++++-------- docs/src/configuring-zed.md | 4 ++-- docs/src/development/glossary.md | 2 +- docs/src/development/linux.md | 2 +- docs/src/extensions/agent-servers.md | 2 +- docs/src/icon-themes.md | 4 ++-- docs/src/key-bindings.md | 8 +++---- docs/src/languages/c.md | 2 +- docs/src/languages/cpp.md | 2 +- docs/src/languages/rust.md | 2 +- docs/src/linux.md | 2 +- docs/src/macos.md | 6 +++--- docs/src/migrate/intellij.md | 6 +++--- docs/src/migrate/pycharm.md | 6 +++--- docs/src/migrate/rustrover.md | 6 +++--- docs/src/migrate/vs-code.md | 10 ++++----- docs/src/migrate/webstorm.md | 6 +++--- docs/src/multibuffers.md | 14 ++++++------ docs/src/outline-panel.md | 4 ++-- docs/src/reference/all-settings.md | 6 +++--- docs/src/reference/cli.md | 2 +- docs/src/repl.md | 12 +++++------ docs/src/semantic-tokens.md | 12 +++++------ docs/src/tasks.md | 20 ++++++++--------- docs/src/terminal.md | 4 ++-- docs/src/themes.md | 4 ++-- docs/src/update.md | 2 +- docs/src/vim.md | 2 +- docs/src/worktree-trust.md | 2 +- 42 files changed, 130 insertions(+), 130 deletions(-) diff --git a/docs/.conventions/CONVENTIONS.md b/docs/.conventions/CONVENTIONS.md index 585971f8fb4b15..b2d49420aec59a 100644 --- a/docs/.conventions/CONVENTIONS.md +++ b/docs/.conventions/CONVENTIONS.md @@ -144,8 +144,8 @@ Use inline `code` for: Use Zed's special syntax for dynamic rendering: -- `{#action git::Commit}` — Renders the action name -- `{#kb git::Commit}` — Renders the keybinding for that action +- {#action git::Commit} — Renders the action name +- {#kb git::Commit} — Renders the keybinding for that action This ensures keybindings stay accurate if defaults change. diff --git a/docs/.doc-examples/configuration.md b/docs/.doc-examples/configuration.md index 4598e19d0a5df9..45fa7e38730299 100644 --- a/docs/.doc-examples/configuration.md +++ b/docs/.doc-examples/configuration.md @@ -32,7 +32,7 @@ The **Settings Editor** ({#kb zed::OpenSettings}) is the primary way to configur To open it: - Press {#kb zed::OpenSettings} -- Or run `zed: open settings` from the command palette +- Or run {#action zed::OpenSettings} from the command palette As you type in the search box, matching settings appear with descriptions and controls to modify them. Changes save automatically to your settings file. @@ -42,7 +42,7 @@ As you type in the search box, matching settings appear with descriptions and co ### User Settings {#user-settings} -Your user settings apply globally across all projects. Open the file with {#kb zed::OpenSettingsFile} or run `zed: open settings file` from the command palette. +Your user settings apply globally across all projects. Open the file with {#kb zed::OpenSettingsFile} or run {#action zed::OpenSettingsFile} from the command palette. The file is located at: diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 54f477472b1b4d..ad35212a6d6bd0 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -38,10 +38,10 @@ Example: The docs use a custom preprocessor (`docs_preprocessor`) that expands special commands: -| Syntax | Purpose | Example | -| ----------------------------- | ------------------------------------- | ------------------------------- | -| `{#kb action::ActionName}` | Keybinding for action | `{#kb agent::ToggleFocus}` | -| `{#action agent::ActionName}` | Action reference (renders as command) | `{#action agent::OpenSettings}` | +| Syntax | Purpose | Example | +| --------------------------- | ------------------------------------- | ----------------------------- | +| {#kb action::ActionName} | Keybinding for action | {#kb agent::ToggleFocus} | +| {#action agent::ActionName} | Action reference (renders as command) | {#action agent::OpenSettings} | **Rules:** diff --git a/docs/README.md b/docs/README.md index 38be153de34b7e..65c4699cb626a9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -50,7 +50,7 @@ When referencing keybindings or actions, use the following formats: ### Keybindings -`{#kb scope::Action}` - e.g., `{#kb zed::OpenSettings}`. +{#kb scope::Action} - e.g., {#kb zed::OpenSettings}. This will output a code element like: `Cmd + , | Ctrl + ,`. We then use a client-side plugin to show the actual keybinding based on the user's platform. @@ -66,7 +66,7 @@ Supported overlays: `jetbrains`. ### Actions -`{#action scope::Action}` - e.g., `{#action zed::OpenSettings}`. +{#action scope::Action} - e.g., {#action zed::OpenSettings}. This will render a human-readable version of the action name, e.g., "zed: open settings", and will allow us to implement things like additional context on hover, etc. diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md index 5f7fe17baec03f..5d75fcf653ecb2 100644 --- a/docs/src/ai/agent-panel.md +++ b/docs/src/ai/agent-panel.md @@ -8,7 +8,7 @@ description: Use Zed's AI coding agent to generate, refactor, and debug code wit The Agent Panel is where you interact with AI agents that can read, write, and run code in your project. It's the core of Zed's AI code editing experience — use it for code generation, refactoring, debugging, documentation, and general questions. -Open it with `agent: new thread` from [the Command Palette](../getting-started.md#command-palette) or click the ✨ icon in the status bar. +Open it with {#action agent::NewThread} from [the Command Palette](../getting-started.md#command-palette) or click the ✨ icon in the status bar. ## Getting Started {#getting-started} @@ -240,7 +240,7 @@ Zed's UI will inform you about this via a warning icon that appears close to the ## Errors and Debugging {#errors-and-debugging} -If you hit an error or unusual LLM behavior, open the thread as Markdown with `agent: open thread as markdown` and attach it to your GitHub issue. +If you hit an error or unusual LLM behavior, open the thread as Markdown with {#action agent::OpenActiveThreadAsMarkdown} and attach it to your GitHub issue. You can also open threads as Markdown by clicking on the file icon button, to the right of the thumbs down button, when focused on the panel's editor. diff --git a/docs/src/ai/agent-settings.md b/docs/src/ai/agent-settings.md index 28ee927e4ab411..488cf141846791 100644 --- a/docs/src/ai/agent-settings.md +++ b/docs/src/ai/agent-settings.md @@ -138,7 +138,7 @@ Specify a custom temperature for a provider and/or model: ## Agent Panel Settings {#agent-panel-settings} -Note that some of these settings are also surfaced in the Agent Panel's settings UI, which you can access either via the `agent: open settings` action or by the dropdown menu on the top-right corner of the panel. +Note that some of these settings are also surfaced in the Agent Panel's settings UI, which you can access either via the {#action agent::OpenSettings} action or by the dropdown menu on the top-right corner of the panel. ### Font Size diff --git a/docs/src/ai/external-agents.md b/docs/src/ai/external-agents.md index 454079c2d26793..50d1a1ce197fb6 100644 --- a/docs/src/ai/external-agents.md +++ b/docs/src/ai/external-agents.md @@ -23,7 +23,7 @@ Under the hood we run Gemini CLI in the background, and talk to it over ACP. First open the agent panel with {#kb agent::ToggleFocus}, and then use the `+` button in the top right to start a new Gemini CLI thread. -If you'd like to bind this to a keyboard shortcut, you can do so by editing your `keymap.json` file via the `zed: open keymap file` command to include: +If you'd like to bind this to a keyboard shortcut, you can do so by editing your `keymap.json` file via the {#action zed::OpenKeymapFile} command to include: ```json [keymap] [ @@ -69,7 +69,7 @@ Under the hood, Zed runs the Claude Agent SDK, which runs Claude Code under the Open the agent panel with {#kb agent::ToggleFocus}, and then use the `+` button in the top right to start a new Claude Agent thread. -If you'd like to bind this to a keyboard shortcut, you can do so by editing your `keymap.json` file via the `zed: open keymap file` command to include: +If you'd like to bind this to a keyboard shortcut, you can do so by editing your `keymap.json` file via the {#action zed::OpenKeymapFile} command to include: ```json [keymap] [ @@ -144,7 +144,7 @@ Under the hood, Zed runs Codex CLI and communicates to it over ACP, through [a d As of version `0.208`, you should be able to use Codex directly from Zed. Open the agent panel with {#kb agent::ToggleFocus}, and then use the `+` button in the top right to start a new Codex thread. -If you'd like to bind this to a keyboard shortcut, you can do so by editing your `keymap.json` file via the `zed: open keymap file` command to include: +If you'd like to bind this to a keyboard shortcut, you can do so by editing your `keymap.json` file via the {#action zed::OpenKeymapFile} command to include: ```json [ @@ -202,7 +202,7 @@ At some point in the near future, Agent Server extensions will be deprecated. Add more external agents to Zed by installing [Agent Server extensions](../extensions/agent-servers.md). -See what agents are available by filtering for "Agent Servers" in the extensions page, which you can access via the command palette with `zed: extensions`, or the [Zed website](https://zed.dev/extensions?filter=agent-servers). +See what agents are available by filtering for "Agent Servers" in the extensions page, which you can access via the command palette with {#action zed::Extensions}, or the [Zed website](https://zed.dev/extensions?filter=agent-servers). ### Via The ACP Registry @@ -216,7 +216,7 @@ At the moment, the registry is a curated set of agents, including only the ones #### Using it in Zed -Use the `zed: acp registry` command to quickly go to the ACP Registry page. +Use the {#action zed::AcpRegistry} command to quickly go to the ACP Registry page. There's also a button ("Add Agent") that takes you there in the agent panel's configuration view. From there, you can click to install your preferred agent and it will become available right away in the `+` icon button in the agent panel. @@ -246,7 +246,7 @@ It's also possible to customize environment variables for registry-installed age ## Debugging Agents -When using external agents in Zed, you can access the debug view via with `dev: open acp logs` from the Command Palette. +When using external agents in Zed, you can access the debug view via with {#action dev::OpenAcpLogs} from the Command Palette. This lets you see the messages being sent and received between Zed and the agent. ![The debug view for ACP logs.](https://zed.dev/img/acp/acp-logs.webp) @@ -339,7 +339,7 @@ For more on configuring MCP servers, see [Model Context Protocol](./mcp.md). 1. Verify the MCP server is enabled in `context_servers` settings 2. For remote MCP servers with OAuth, this is a [known issue](https://github.com/zed-industries/zed/issues/54410) — try local stdio-based servers instead -3. Open `dev: open acp logs` from the Command Palette to debug +3. Open {#action dev::OpenAcpLogs} from the Command Palette to debug **"My existing Claude Code / Codex setup isn't working in Zed"** diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index b32c433803f6cd..e1b5a50779fb30 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -13,7 +13,7 @@ You can do that by either subscribing to [one of Zed's plans](./plans-and-usage. If you already have an API key for a provider like Anthropic or OpenAI, you can add it to Zed. No Zed subscription required. -To add an existing API key to a given provider, go to the Agent Panel settings (`agent: open settings`), look for the desired provider, paste the key into the input, and hit enter. +To add an existing API key to a given provider, go to the Agent Panel settings ({#action agent::OpenSettings}), look for the desired provider, paste the key into the input, and hit enter. > Note: API keys are _not_ stored as plain text in your settings file, but rather in your OS's secure credential storage. @@ -70,7 +70,7 @@ With that done, choose one of the three authentication methods: #### Authentication via Named Profile (Recommended) 1. Ensure you have the AWS CLI installed and configured with a named profile -2. Open your settings file (`zed: open settings file`) and include the `bedrock` key under `language_models` with the following settings: +2. Open your settings file ({#action zed::OpenSettingsFile}) and include the `bedrock` key under `language_models` with the following settings: ```json [settings] { "language_models": { @@ -90,7 +90,7 @@ To do this: 1. Create an IAM User in the [IAM Console](https://us-east-1.console.aws.amazon.com/iam/home?region=us-east-1#/users). 2. Create security credentials for that User, save them and keep them secure. -3. Open the Agent Configuration with (`agent: open settings`) and go to the Amazon Bedrock section +3. Open the Agent Configuration with ({#action agent::OpenSettings}) and go to the Amazon Bedrock section 4. Copy the credentials from Step 2 into the respective **Access Key ID**, **Secret Access Key**, and **Region** fields. #### Authentication via Bedrock API Key @@ -98,7 +98,7 @@ To do this: Amazon Bedrock also supports [API Keys](https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys-use.html), which authenticate directly without requiring IAM users or named profiles. 1. Create an API Key in the [Amazon Bedrock Console](https://console.aws.amazon.com/bedrock/) -2. Open the Agent Configuration with (`agent: open settings`) and go to the Amazon Bedrock section +2. Open the Agent Configuration with ({#action agent::OpenSettings}) and go to the Amazon Bedrock section 3. Enter your Bedrock API key in the **API Key** field and select your **Region** ```json [settings] @@ -179,7 +179,7 @@ You can use Anthropic models by choosing them via the model dropdown in the Agen 1. Sign up for Anthropic and [create an API key](https://console.anthropic.com/settings/keys) 2. Make sure that your Anthropic account has credits -3. Open the settings view (`agent: open settings`) and go to the Anthropic section +3. Open the settings view ({#action agent::OpenSettings}) and go to the Anthropic section 4. Enter your Anthropic API key Even if you pay for Claude Pro, you will still have to [pay for additional credits](https://console.anthropic.com/settings/plans) to use it via the API. @@ -232,7 +232,7 @@ You can configure a model to use [extended thinking](https://docs.anthropic.com/ ### DeepSeek {#deepseek} 1. Visit the DeepSeek platform and [create an API key](https://platform.deepseek.com/api_keys) -2. Open the settings view (`agent: open settings`) and go to the DeepSeek section +2. Open the settings view ({#action agent::OpenSettings}) and go to the DeepSeek section 3. Enter your DeepSeek API key The DeepSeek API key will be saved in your keychain. @@ -275,7 +275,7 @@ You can also modify the `api_url` to use a custom endpoint if needed. You can use GitHub Copilot Chat with the Zed agent by choosing it via the model dropdown in the Agent Panel. -1. Open the settings view (`agent: open settings`) and go to the GitHub Copilot Chat section +1. Open the settings view ({#action agent::OpenSettings}) and go to the GitHub Copilot Chat section 2. Click on `Sign in to use GitHub Copilot`, follow the steps shown in the modal. Alternatively, you can provide an OAuth token via the `GH_COPILOT_TOKEN` environment variable. @@ -289,7 +289,7 @@ To use Copilot Enterprise with Zed (for both agent and completions), you must co You can use Gemini models with the Zed agent by choosing it via the model dropdown in the Agent Panel. 1. Go to the Google AI Studio site and [create an API key](https://aistudio.google.com/app/apikey). -2. Open the settings view (`agent: open settings`) and go to the Google AI section +2. Open the settings view ({#action agent::OpenSettings}) and go to the Google AI section 3. Enter your Google AI API key and press enter. The Google AI API key will be saved in your keychain. @@ -353,7 +353,7 @@ Tip: Set [LM Studio as a login item](https://lmstudio.ai/docs/advanced/headless# ### Mistral {#mistral} 1. Visit the Mistral platform and [create an API key](https://console.mistral.ai/api-keys/) -2. Open the configuration view (`agent: open settings`) and navigate to the Mistral section +2. Open the configuration view ({#action agent::OpenSettings}) and navigate to the Mistral section 3. Enter your Mistral API key The Mistral API key will be saved in your keychain. @@ -502,7 +502,7 @@ One such service is [Ollama Turbo](https://ollama.com/turbo). To configure Zed t 1. Sign in to your Ollama account and subscribe to Ollama Turbo 2. Visit [ollama.com/settings/keys](https://ollama.com/settings/keys) and create an API key -3. Open the settings view (`agent: open settings`) and go to the Ollama section +3. Open the settings view ({#action agent::OpenSettings}) and go to the Ollama section 4. Paste your API key and press enter. 5. For the API URL enter `https://ollama.com` @@ -512,7 +512,7 @@ Zed will also use the `OLLAMA_API_KEY` environment variables if defined. 1. Visit the OpenAI platform and [create an API key](https://platform.openai.com/account/api-keys) 2. Make sure that your OpenAI account has credits -3. Open the settings view (`agent: open settings`) and go to the OpenAI section +3. Open the settings view ({#action agent::OpenSettings}) and go to the OpenAI section 4. Enter your OpenAI API key The OpenAI API key will be saved in your keychain. @@ -570,7 +570,7 @@ This is useful for connecting to other hosted services (like Together AI, Anysca You can add a custom, OpenAI-compatible model either via the UI or by editing your settings file. -To do it via the UI, go to the Agent Panel settings (`agent: open settings`) and look for the "Add Provider" button to the right of the "LLM Providers" section title. +To do it via the UI, go to the Agent Panel settings ({#action agent::OpenSettings}) and look for the "Add Provider" button to the right of the "LLM Providers" section title. Then, fill up the input fields available in the modal. To do it via your settings file ([how to edit](../configuring-zed.md#settings-files)), add the following snippet under `language_models`: @@ -626,7 +626,7 @@ OpenCode offers multiple ways to access AI models: 1. Visit [OpenCode Console](https://opencode.ai/auth) and create an account 2. Free models are available without payment. To use Zen or Go models, make sure you have enough credits or an active subscription 3. Generate an API key from the "API Keys" section in the OpenCode Console -4. Open the settings view (`agent: open settings`) and go to the OpenCode section +4. Open the settings view ({#action agent::OpenSettings}) and go to the OpenCode section 5. Enter your OpenCode API key The OpenCode API key will be saved in your keychain. @@ -693,7 +693,7 @@ OpenRouter provides access to multiple AI models through a single API. It suppor 1. Visit [OpenRouter](https://openrouter.ai) and create an account 2. Generate an API key from your [OpenRouter keys page](https://openrouter.ai/keys) -3. Open the settings view (`agent: open settings`) and go to the OpenRouter section +3. Open the settings view ({#action agent::OpenSettings}) and go to the OpenRouter section 4. Enter your OpenRouter API key The OpenRouter API key will be saved in your keychain. @@ -812,7 +812,7 @@ These routing controls let you fine‑tune cost, capability, and reliability tra [Vercel AI Gateway](https://vercel.com/ai-gateway) provides access to many models through a single OpenAI-compatible endpoint. 1. Create an API key from your [Vercel AI Gateway keys page](https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys&title=Go+to+AI+Gateway) -2. Open the settings view (`agent: open settings`) and go to the **Vercel AI Gateway** section +2. Open the settings view ({#action agent::OpenSettings}) and go to the **Vercel AI Gateway** section 3. Enter your Vercel AI Gateway API key The Vercel AI Gateway API key will be saved in your keychain. @@ -836,7 +836,7 @@ You can also set a custom endpoint for Vercel AI Gateway in your settings file: Zed includes a dedicated [xAI](https://x.ai/) provider. You can use your own API key to access Grok models. 1. [Create an API key in the xAI Console](https://console.x.ai/team/default/api-keys) -2. Open the settings view (`agent: open settings`) and go to the **xAI** section +2. Open the settings view ({#action agent::OpenSettings}) and go to the **xAI** section 3. Enter your xAI API key The xAI API key will be saved in your keychain. Zed will also use the `XAI_API_KEY` environment variable if it's defined. diff --git a/docs/src/ai/mcp.md b/docs/src/ai/mcp.md index dbe2f10af039f8..fb3e2b25e0131f 100644 --- a/docs/src/ai/mcp.md +++ b/docs/src/ai/mcp.md @@ -26,7 +26,7 @@ Check out the [MCP Server Extensions](../extensions/mcp-extensions.md) page to l Many MCP servers are available as extensions. Find them via: 1. [the Zed website](https://zed.dev/extensions?filter=context-servers) -2. in the app, open the Command Palette and run the `zed: extensions` action +2. in the app, open the Command Palette and run the {#action zed::Extensions} action 3. in the app, go to the Agent Panel's top-right menu and look for the "View Server Extensions" menu item Popular servers available as an extension include: @@ -64,7 +64,7 @@ You can connect them by adding their commands directly to your settings file ([h } ``` -Alternatively, you can also add a custom server by accessing the Agent Panel's Settings view (also accessible via the `agent: open settings` action). +Alternatively, you can also add a custom server by accessing the Agent Panel's Settings view (also accessible via the {#action agent::OpenSettings} action). From there, you can add it through the modal that appears when you click the "Add Custom Server" button. > Note: When a remote MCP server has no configured `"Authorization"` header, Zed will prompt you to authenticate yourself against the MCP server using the standard MCP OAuth flow. diff --git a/docs/src/appearance.md b/docs/src/appearance.md index 1c26d671003794..26c268e28c120c 100644 --- a/docs/src/appearance.md +++ b/docs/src/appearance.md @@ -17,7 +17,7 @@ Here's how to make Zed feel like home: 2. **Toggle light/dark mode quickly**: Press {#kb theme::ToggleMode}. If you currently use a static `"theme": "..."` value, the first toggle converts it to dynamic mode settings with default themes. -3. **Choose an icon theme**: Run `icon theme selector: toggle` from the command palette to browse icon themes. +3. **Choose an icon theme**: Run {#action icon_theme_selector::Toggle} from the command palette to browse icon themes. 4. **Set your font**: Open the Settings Editor with {#kb zed::OpenSettings} and search for `buffer_font_family`. Set it to your preferred coding font. @@ -47,7 +47,7 @@ You can also override specific theme attributes for fine-grained control. ## Icon Themes -Customize file and folder icons in the Project Panel and tabs. Browse available icon themes with the Icon Theme Selector (`icon theme selector: toggle` in the command palette). +Customize file and folder icons in the Project Panel and tabs. Browse available icon themes with the Icon Theme Selector ({#action icon_theme_selector::Toggle} in the command palette). Like color themes, icon themes support separate light and dark variants: diff --git a/docs/src/authentication.md b/docs/src/authentication.md index 0f3dd2ecbce95b..59809ba410b43e 100644 --- a/docs/src/authentication.md +++ b/docs/src/authentication.md @@ -16,7 +16,7 @@ Signing in to Zed is not required. You can use most features you'd expect in a c Zed uses GitHub's OAuth flow to authenticate users, requiring only the `read:user` GitHub scope, which grants read-only access to your GitHub profile information. -1. Open Zed and click the `Sign In` button in the top-right corner of the window, or run the `client: sign in` command from the command palette (`cmd-shift-p` on macOS or `ctrl-shift-p` on Windows/Linux). +1. Open Zed and click the `Sign In` button in the top-right corner of the window, or run the {#action client::SignIn} command from the command palette (`cmd-shift-p` on macOS or `ctrl-shift-p` on Windows/Linux). 2. Your default web browser will open to the Zed sign-in page. 3. Authenticate with your GitHub account when prompted. 4. After successful authentication, your browser will display a confirmation, and you'll be automatically signed in to Zed. @@ -28,7 +28,7 @@ Zed uses GitHub's OAuth flow to authenticate users, requiring only the `read:use To sign out of Zed, you can use either of these methods: - Click on the profile icon in the upper right corner and select `Sign Out` from the dropdown menu. -- Open the command palette and run the `client: sign out` command. +- Open the command palette and run the {#action client::SignOut} command. ## Email Addresses {#email} diff --git a/docs/src/collaboration/channels.md b/docs/src/collaboration/channels.md index a07979fc019160..dc8a5eb833b7ed 100644 --- a/docs/src/collaboration/channels.md +++ b/docs/src/collaboration/channels.md @@ -73,7 +73,7 @@ Open channel notes by clicking the document icon to the right of the channel nam ## Following Collaborators To follow a collaborator, click on their avatar in the top left of the title bar. -You can also cycle through collaborators using {#kb workspace::FollowNextCollaborator} or `workspace: follow next collaborator` in the command palette. +You can also cycle through collaborators using {#kb workspace::FollowNextCollaborator} or {#action workspace::FollowNextCollaborator} in the command palette. When you join a project, you'll immediately start following the collaborator that invited you. diff --git a/docs/src/command-palette.md b/docs/src/command-palette.md index 89f7fc6c60609c..cff57ca2c0655e 100644 --- a/docs/src/command-palette.md +++ b/docs/src/command-palette.md @@ -9,6 +9,6 @@ The Command Palette is the main way to access actions in Zed. Its keybinding is ![The opened Command Palette](https://zed.dev/img/features/command-palette.jpg) -To try it, open the Command Palette and type `new file`. The command list should narrow to `workspace: new file`. Press Return to create a new buffer. +To try it, open the Command Palette and type `new file`. The command list should narrow to {#action workspace::NewFile}. Press Return to create a new buffer. Any time you see instructions that include commands of the form `zed: ...` or `editor: ...` and so on that means you need to execute them in the Command Palette. diff --git a/docs/src/configuring-languages.md b/docs/src/configuring-languages.md index 01c884622edd2e..d4e76534fd1b66 100644 --- a/docs/src/configuring-languages.md +++ b/docs/src/configuring-languages.md @@ -353,7 +353,7 @@ To run linter fixes automatically on save: ### Formatting Selections -Zed supports formatting only the selected text via `editor: format selections` ({#kb editor::FormatSelections}). How +Zed supports formatting only the selected text via {#action editor::FormatSelections} ({#kb editor::FormatSelections}). How this works depends on the configured formatter: - The action is only shown when the active formatter can actually format ranges for at least one @@ -395,7 +395,7 @@ Zed allows you to run both formatting and linting on save. Here's an example tha If you encounter issues with formatting or linting: -1. Check Zed's log file for error messages (Use the command palette: `zed: open log`) +1. Check Zed's log file for error messages (Use the command palette: {#action zed::OpenLog}) 2. Ensure external tools (formatters, linters) are correctly installed and in your PATH 3. Verify configurations in both Zed settings and language-specific config files (e.g., `.eslintrc`, `.prettierrc`) @@ -482,22 +482,22 @@ For language-specific inlay hint settings, refer to the documentation for each l ### Code Actions -Code actions provide quick fixes and refactoring options. Access code actions using the `editor: Toggle Code Actions` command or by clicking the lightbulb icon that appears next to your cursor when actions are available. +Code actions provide quick fixes and refactoring options. Access code actions using the {#action editor::ToggleCodeActions} command or by clicking the lightbulb icon that appears next to your cursor when actions are available. ### Go To Definition and References Use these commands to navigate your codebase: -- `editor: Go to Definition` (f12|f12) -- `editor: Go to Type Definition` (cmd-f12|ctrl-f12) -- `editor: Find All References` (shift-f12|shift-f12) +- {#action editor::GoToDefinition} (f12|f12) +- {#action editor::GoToTypeDefinition} (cmd-f12|ctrl-f12) +- {#action editor::FindAllReferences} (shift-f12|shift-f12) ### Rename Symbol To rename a symbol across your project: 1. Place your cursor on the symbol -2. Use the `editor: Rename Symbol` command (f2|f2) +2. Use the {#action editor::Rename} command (f2|f2) 3. Enter the new name and press Enter These features depend on the capabilities of the language server for each language. @@ -506,7 +506,7 @@ When renaming a symbol that spans multiple files, Zed will open a preview in a m ### Hover Information -Use the `editor: Hover` command to display information about the symbol under the cursor. This often includes type information, documentation, and links to relevant resources. +Use the {#action editor::Hover} command to display information about the symbol under the cursor. This often includes type information, documentation, and links to relevant resources. ### Workspace Symbol Search @@ -514,7 +514,7 @@ The {#action project_symbols::Toggle} command allows you to search for symbols ( ### Code Completion -Zed provides intelligent code completion suggestions as you type. You can manually trigger completion with the `editor: Show Completions` command. Use tab|tab or enter|enter to accept suggestions. +Zed provides intelligent code completion suggestions as you type. You can manually trigger completion with the {#action editor::ShowCompletions} command. Use tab|tab or enter|enter to accept suggestions. ### Diagnostics diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index b2a8c1e88a4abb..2ca5c215ec86fb 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -16,7 +16,7 @@ The **Settings Editor** ({#kb zed::OpenSettings}) is the primary way to configur To open it: - Press {#kb zed::OpenSettings} -- Or run `zed: open settings` from the command palette +- Or run {#action zed::OpenSettings} from the command palette As you type in the search box, matching settings appear with descriptions and controls to modify them. Changes save automatically to your settings file. @@ -26,7 +26,7 @@ As you type in the search box, matching settings appear with descriptions and co ### User Settings -Your user settings apply globally across all projects. Open the file with {#kb zed::OpenSettingsFile} or run `zed: open settings file` from the command palette. +Your user settings apply globally across all projects. Open the file with {#kb zed::OpenSettingsFile} or run {#action zed::OpenSettingsFile} from the command palette. The file is located at: diff --git a/docs/src/development/glossary.md b/docs/src/development/glossary.md index 1f6b07840b8c70..4e14aceba40005 100644 --- a/docs/src/development/glossary.md +++ b/docs/src/development/glossary.md @@ -44,7 +44,7 @@ for any type name, such as `AnyElement` or `LspStore`. - `Global`: A singleton type which has only one value, that is stored in the `App`. - `Event`: A data type that can be sent by an `Entity` to subscribers. - `Action`: An event that represents a user's keyboard input that can be handled by listeners - Example: `file finder: toggle` + Example: {#action file_finder::Toggle} - `Observing`: Reacting to notifications that entities have changed. - `Subscription`: An event handler that is used to react to the changes of state in the application. 1. Emitted event handling diff --git a/docs/src/development/linux.md b/docs/src/development/linux.md index 56545111fd11ba..77af9c8420ed63 100644 --- a/docs/src/development/linux.md +++ b/docs/src/development/linux.md @@ -159,7 +159,7 @@ Use this when Zed is using a lot of CPU. It is not useful for hangs. run `sudo chown $USER:$USER perf.data` - Get build info: - Run zed again and type `zed: about` in the command pallet to get the exact commit. + Run zed again and type {#action zed::About} in the command pallet to get the exact commit. The `perf.data` file can be sent to Zed together with the exact commit. diff --git a/docs/src/extensions/agent-servers.md b/docs/src/extensions/agent-servers.md index 60289f40cf8652..23d8c8881252ab 100644 --- a/docs/src/extensions/agent-servers.md +++ b/docs/src/extensions/agent-servers.md @@ -17,7 +17,7 @@ At some point in the near future, Agent Server extensions will be deprecated. Agent Servers are programs that provide AI agent implementations through the [Agent Client Protocol (ACP)](https://agentclientprotocol.com). Agent Server Extensions let you package an Agent Server so users can install the extension and use your agent in Zed. -You can see the current Agent Server extensions either by opening the Extensions tab in Zed (execute the `zed: extensions` command) and changing the filter from `All` to `Agent Servers`, or by visiting [the Zed website](https://zed.dev/extensions?filter=agent-servers). +You can see the current Agent Server extensions either by opening the Extensions tab in Zed (execute the {#action zed::Extensions} command) and changing the filter from `All` to `Agent Servers`, or by visiting [the Zed website](https://zed.dev/extensions?filter=agent-servers). ## Defining Agent Server Extensions diff --git a/docs/src/icon-themes.md b/docs/src/icon-themes.md index 9d4b38700aae9a..5eb7e95f3e9570 100644 --- a/docs/src/icon-themes.md +++ b/docs/src/icon-themes.md @@ -9,13 +9,13 @@ Zed comes with a built-in icon theme, with more icon themes available as extensi ## Selecting an Icon Theme -See what icon themes are installed and preview them via the Icon Theme Selector, which you can open from the command palette with `icon theme selector: toggle`. +See what icon themes are installed and preview them via the Icon Theme Selector, which you can open from the command palette with {#action icon_theme_selector::Toggle}. Navigating through the icon theme list by moving up and down will change the icon theme in real time and hitting enter will save it to your settings file. ## Installing more Icon Themes -More icon themes are available from the Extensions page, which you can access via the command palette with `zed: extensions` or the [Zed website](https://zed.dev/extensions?filter=icon-themes). +More icon themes are available from the Extensions page, which you can access via the command palette with {#action zed::Extensions} or the [Zed website](https://zed.dev/extensions?filter=icon-themes). ## Configuring Icon Themes diff --git a/docs/src/key-bindings.md b/docs/src/key-bindings.md index 7b449fea05aabd..ae64ab00b8ccd8 100644 --- a/docs/src/key-bindings.md +++ b/docs/src/key-bindings.md @@ -21,7 +21,7 @@ We currently support: - Cursor - None (disables _all_ key bindings) -This setting can also be changed via the command palette through the `zed: toggle base keymap selector` action. +This setting can also be changed via the command palette through the {#action zed::ToggleBaseKeymapSelector} action. You can also enable `vim_mode` or `helix_mode`, which add modal bindings. For more information, see the documentation for [Vim mode](./vim.md) and [Helix mode](./helix.md). @@ -79,7 +79,7 @@ You can see all of Zed's default bindings for each platform in the default keyma - [Windows](https://github.com/zed-industries/zed/blob/main/assets/keymaps/default-windows.json) - [Linux](https://github.com/zed-industries/zed/blob/main/assets/keymaps/default-linux.json). -If you want to debug problems with custom keymaps, you can use `dev: Open Key Context View` from the command palette. +If you want to debug problems with custom keymaps, you can use {#action dev::OpenKeyContextView} from the command palette. Please file [an issue](https://github.com/zed-industries/zed) if you run into something you think should work but isn't. ### Keybinding Syntax @@ -120,7 +120,7 @@ It is possible to match against typing a modifier key on its own. For example, ` If a binding group has a `"context"` key, it will be matched against the currently active contexts in Zed. -Zed's contexts make up a tree, with the root being `Workspace`. Workspaces contain Panes and Panels, and Panes contain Editors, etc. The easiest way to see what contexts are active at a given moment is the key context view, which you can get to with the `dev: open key context view` command in the command palette. +Zed's contexts make up a tree, with the root being `Workspace`. Workspaces contain Panes and Panels, and Panes contain Editors, etc. The easiest way to see what contexts are active at a given moment is the key context view, which you can get to with the {#action dev::OpenKeyContextView} command in the command palette. For example: @@ -186,7 +186,7 @@ Otherwise, read on... On Cyrillic, Hebrew, Armenian, and other keyboards that are mostly non-ASCII, macOS automatically maps keys to the ASCII range when `cmd` is held. Zed takes this a step further, and it can always match key-presses against either the ASCII layout or the real layout, regardless of modifiers and the `use_key_equivalents` setting. For example, in Thai, pressing `ctrl-ๆ` will match bindings associated with `ctrl-q` or `ctrl-ๆ`. -On keyboards that support extended Latin alphabets (French AZERTY, German QWERTZ, etc.), it is often not possible to type the entire ASCII range without `option`. This introduces an ambiguity: `option-2` produces `@`. To ensure that all the built-in keyboard shortcuts can still be typed on these keyboards, we move key bindings around. For example, shortcuts bound to `@` on QWERTY are moved to `"` on a Spanish layout. This mapping is based on the macOS system defaults and can be seen by running `dev: open key context view` from the command palette. +On keyboards that support extended Latin alphabets (French AZERTY, German QWERTZ, etc.), it is often not possible to type the entire ASCII range without `option`. This introduces an ambiguity: `option-2` produces `@`. To ensure that all the built-in keyboard shortcuts can still be typed on these keyboards, we move key bindings around. For example, shortcuts bound to `@` on QWERTY are moved to `"` on a Spanish layout. This mapping is based on the macOS system defaults and can be seen by running {#action dev::OpenKeyContextView} from the command palette. If you are defining shortcuts in your personal keymap, you can opt into the key equivalent mapping by setting `use_key_equivalents` to `true` in your keymap: diff --git a/docs/src/languages/c.md b/docs/src/languages/c.md index a4fb8a188a2fc0..4fc054851c2ef4 100644 --- a/docs/src/languages/c.md +++ b/docs/src/languages/c.md @@ -45,7 +45,7 @@ IndentWidth: 2 See [Clang-Format Style Options](https://clang.llvm.org/docs/ClangFormatStyleOptions.html) for a complete list of options. -You can trigger formatting via {#kb editor::Format} or the `editor: format` action from the command palette or by enabling format on save. +You can trigger formatting via {#kb editor::Format} or the {#action editor::Format} action from the command palette or by enabling format on save. Configure formatting in Settings ({#kb zed::OpenSettings}) under Languages > C, or add to your settings file: diff --git a/docs/src/languages/cpp.md b/docs/src/languages/cpp.md index 7fad9a52606964..1f63460160cc1e 100644 --- a/docs/src/languages/cpp.md +++ b/docs/src/languages/cpp.md @@ -97,7 +97,7 @@ PointerAlignment: Left See [Clang-Format Style Options](https://clang.llvm.org/docs/ClangFormatStyleOptions.html) for a complete list of options. -You can trigger formatting via {#kb editor::Format} or the `editor: format` action from the command palette or by enabling format on save. +You can trigger formatting via {#kb editor::Format} or the {#action editor::Format} action from the command palette or by enabling format on save. Configure formatting in Settings ({#kb zed::OpenSettings}) under Languages > C++, or add to your settings file: diff --git a/docs/src/languages/rust.md b/docs/src/languages/rust.md index 164cac4994505b..8568ca27ffd2d3 100644 --- a/docs/src/languages/rust.md +++ b/docs/src/languages/rust.md @@ -155,7 +155,7 @@ This is enabled by default and can be configured as ## Manual Cargo Diagnostics fetch By default, rust-analyzer has `checkOnSave: true` enabled, which causes every buffer save to trigger a `cargo check --workspace --all-targets` command. -If disabled with `checkOnSave: false` (see the example of the server configuration json above), it's still possible to fetch the diagnostics manually, with the `editor: run/clear/cancel flycheck` commands in Rust files to refresh cargo diagnostics; the project diagnostics editor will also refresh cargo diagnostics with `editor: run flycheck` command when the setting is enabled. +If disabled with `checkOnSave: false` (see the example of the server configuration json above), it's still possible to fetch the diagnostics manually, with the `editor: run/clear/cancel flycheck` commands in Rust files to refresh cargo diagnostics; the project diagnostics editor will also refresh cargo diagnostics with {#action editor::RunFlycheck} command when the setting is enabled. ## More server configuration diff --git a/docs/src/linux.md b/docs/src/linux.md index 6ebb179db3389b..319c74960ed19e 100644 --- a/docs/src/linux.md +++ b/docs/src/linux.md @@ -205,7 +205,7 @@ Using [vkdevicechooser](https://github.com/jiriks74/vkdevicechooser). If Vulkan is configured correctly, and Zed is still not working for you, please [file an issue](https://github.com/zed-industries/zed) with as much information as possible. -When reporting issues where Zed fails to start due to graphics initialization errors on GitHub, it can be impossible to run the `zed: copy system specs into clipboard` command like we instruct you to in our issue template. We provide an alternative way to collect the system specs specifically for this situation. +When reporting issues where Zed fails to start due to graphics initialization errors on GitHub, it can be impossible to run the {#action zed::CopySystemSpecsIntoClipboard} command like we instruct you to in our issue template. We provide an alternative way to collect the system specs specifically for this situation. Passing the `--system-specs` flag to Zed like diff --git a/docs/src/macos.md b/docs/src/macos.md index 4c95c86122f7e1..b9438185c67006 100644 --- a/docs/src/macos.md +++ b/docs/src/macos.md @@ -46,7 +46,7 @@ Zed includes a command-line tool for opening files and projects from Terminal. T 1. Open Zed 2. Open the command palette with `Cmd+Shift+P` -3. Run `cli: install` +3. Run {#action cli::InstallCliBinary} This creates a `zed` command in `/usr/local/bin`. You can then open files and folders: @@ -101,7 +101,7 @@ xattr -cr /Applications/Zed.app If the `zed` command isn't available after installation: 1. Check that `/usr/local/bin` is in your PATH -2. Try reinstalling the CLI via `cli: install` in the command palette +2. Try reinstalling the CLI via {#action cli::InstallCliBinary} in the command palette 3. Open a new terminal window to reload your PATH ### GPU or rendering issues @@ -116,7 +116,7 @@ Zed uses Metal for rendering. If you experience graphical glitches: If Zed uses more resources than expected: -1. Check for runaway language servers in the terminal output (`zed: open log`) +1. Check for runaway language servers in the terminal output ({#action zed::OpenLog}) 2. Try disabling extensions one by one to identify conflicts 3. For large projects, consider using [project settings](./reference/all-settings.md#file-scan-exclusions) to exclude unnecessary folders from indexing diff --git a/docs/src/migrate/intellij.md b/docs/src/migrate/intellij.md index 74f7cf226c8620..a6a3773affc77a 100644 --- a/docs/src/migrate/intellij.md +++ b/docs/src/migrate/intellij.md @@ -45,7 +45,7 @@ This maps familiar shortcuts like `Shift Shift` for Search Everywhere, `Cmd+O` f ## Set Up Editor Preferences -You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run `zed: open settings file` from the Command Palette to edit your settings file directly. +You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run {#action zed::OpenSettingsFile} from the Command Palette to edit your settings file directly. Settings IntelliJ users typically configure first: @@ -125,7 +125,7 @@ If you chose the JetBrains keymap during onboarding, most of your shortcuts shou ### How to Customize Keybindings - Open the Command Palette (`Cmd+Shift+A` or `Shift Shift`) -- Run `Zed: Open Keymap Editor` +- Run {#action zed::OpenKeymap} This opens a list of all available bindings. You can override individual shortcuts or remove conflicts. @@ -182,7 +182,7 @@ This means: **How to adapt:** - Create a `.zed/settings.json` in your project root for project-specific settings -- Define common commands in `tasks.json` (open via Command Palette: `zed: open tasks`): +- Define common commands in `tasks.json` (open via Command Palette: {#action zed::OpenTasks}): ```json [ diff --git a/docs/src/migrate/pycharm.md b/docs/src/migrate/pycharm.md index 9f45135268e518..95c37dcc9a1ca4 100644 --- a/docs/src/migrate/pycharm.md +++ b/docs/src/migrate/pycharm.md @@ -45,7 +45,7 @@ This maps familiar shortcuts like `Shift Shift` for Search Everywhere, `Cmd+O` f ## Set Up Editor Preferences -You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run `zed: open settings file` from the Command Palette to edit your settings file directly. +You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run {#action zed::OpenSettingsFile} from the Command Palette to edit your settings file directly. Settings PyCharm users typically configure first: @@ -125,7 +125,7 @@ If you chose the JetBrains keymap during onboarding, most of your shortcuts shou ### How to Customize Keybindings - Open the Command Palette (`Cmd+Shift+A` or `Shift Shift`) -- Run `Zed: Open Keymap Editor` +- Run {#action zed::OpenKeymap} This opens a list of all available bindings. You can override individual shortcuts or remove conflicts. @@ -211,7 +211,7 @@ This means: **How to adapt:** - Create a `.zed/settings.json` in your project root for project-specific settings -- Define common commands in `tasks.json` (open via Command Palette: `zed: open tasks`): +- Define common commands in `tasks.json` (open via Command Palette: {#action zed::OpenTasks}): ```json [ diff --git a/docs/src/migrate/rustrover.md b/docs/src/migrate/rustrover.md index 34cf03393e649f..f4a8bccd6e3f2e 100644 --- a/docs/src/migrate/rustrover.md +++ b/docs/src/migrate/rustrover.md @@ -45,7 +45,7 @@ This maps familiar shortcuts like `Shift Shift` for Search Everywhere, `Cmd+O` f ## Set Up Editor Preferences -You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run `zed: open settings file` from the Command Palette to edit your settings file directly. +You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run {#action zed::OpenSettingsFile} from the Command Palette to edit your settings file directly. Settings RustRover users typically configure first: @@ -138,7 +138,7 @@ If you chose the JetBrains keymap during onboarding, most of your shortcuts shou ### How to Customize Keybindings - Open the Command Palette (`Cmd+Shift+A` or `Shift Shift`) -- Run `Zed: Open Keymap Editor` +- Run {#action zed::OpenKeymap} This opens a list of all available bindings. You can override individual shortcuts or remove conflicts. @@ -183,7 +183,7 @@ Both editors store per-project configuration in a hidden folder. RustRover uses **How to adapt:** - Create a `.zed/settings.json` in your project root for project-specific settings -- Define common commands in `tasks.json` (open via Command Palette: `zed: open tasks`): +- Define common commands in `tasks.json` (open via Command Palette: {#action zed::OpenTasks}): ```json [ diff --git a/docs/src/migrate/vs-code.md b/docs/src/migrate/vs-code.md index b2f3049fce10b0..86b36e044463ce 100644 --- a/docs/src/migrate/vs-code.md +++ b/docs/src/migrate/vs-code.md @@ -166,11 +166,11 @@ The following VS Code settings are automatically imported when you use **Import Zed doesn’t import extensions or keybindings, but this import gets core editor behavior close to your VS Code setup. If you skip that step during setup, you can still import settings manually later via the command palette: -`Cmd+Shift+P → Zed: Import VS Code Settings` +`Cmd+Shift+P → {#action zed::ImportVsCodeSettings}` ## Set Up Editor Preferences -You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run `zed: open settings file` from the Command Palette to edit your settings file directly. +You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run {#action zed::OpenSettingsFile} from the Command Palette to edit your settings file directly. Here’s how common VS Code settings translate: | VS Code | Zed | Notes | @@ -244,7 +244,7 @@ Here’s a quick reference for where keybindings match and where they differ. To edit your keybindings: - Open the command palette (`Cmd+Shift+P`) -- Run `Zed: Open Keymap Editor` +- Run {#action zed::OpenKeymap} This opens a list of all available bindings. You can override individual shortcuts, remove conflicts, or build a layout that works better for your setup. @@ -352,7 +352,7 @@ Here are a few useful tweaks: "load_direnv": "shell_hook" ``` -**Custom Tasks**: Define build or run commands in your `tasks.json` (accessed via command palette: `zed: open tasks`): +**Custom Tasks**: Define build or run commands in your `tasks.json` (accessed via command palette: {#action zed::OpenTasks}): ```json [ @@ -364,4 +364,4 @@ Here are a few useful tweaks: ``` **Bring over custom snippets** -Copy your VS Code snippet JSON directly into Zed's snippets folder (`zed: configure snippets`). +Copy your VS Code snippet JSON directly into Zed's snippets folder ({#action snippets::ConfigureSnippets}). diff --git a/docs/src/migrate/webstorm.md b/docs/src/migrate/webstorm.md index e5313251ec1234..0aa9c43f167a0e 100644 --- a/docs/src/migrate/webstorm.md +++ b/docs/src/migrate/webstorm.md @@ -45,7 +45,7 @@ This maps familiar shortcuts like {#kb:jetbrains project_symbols::Toggle} for Go ## Set Up Editor Preferences -You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run `zed: open settings file` from the Command Palette to edit your settings file directly. +You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run {#action zed::OpenSettingsFile} from the Command Palette to edit your settings file directly. Settings WebStorm users typically configure first: @@ -118,7 +118,7 @@ If you chose the JetBrains keymap during onboarding, most of your shortcuts shou ### How to Customize Keybindings - Open the Command Palette ({#kb:jetbrains command_palette::Toggle}) -- Run `zed: open keymap` +- Run {#action zed::OpenKeymap} This opens a list of all available bindings. You can override individual shortcuts or remove conflicts. @@ -182,7 +182,7 @@ What this means in practice: **How to adapt:** - Create a `.zed/settings.json` in your project root for project-specific settings -- Define common commands in `tasks.json` (open via Command Palette: `zed: open tasks`): +- Define common commands in `tasks.json` (open via Command Palette: {#action zed::OpenTasks}): ```json [ diff --git a/docs/src/multibuffers.md b/docs/src/multibuffers.md index 5408c44597beee..0033f86c0fb336 100644 --- a/docs/src/multibuffers.md +++ b/docs/src/multibuffers.md @@ -18,28 +18,28 @@ One of the superpowers Zed gives you is the ability to edit multiple files simul > -Editing a multibuffer is the same as editing a normal file. Changes you make will be reflected in the open copies of that file in the rest of the editor, and you can save all files with `editor: Save` (bound to `cmd-s` on macOS, `ctrl-s` on Windows/Linux, or `:w` in Vim mode). +Editing a multibuffer is the same as editing a normal file. Changes you make will be reflected in the open copies of that file in the rest of the editor, and you can save all files with {#action workspace::Save} (bound to `cmd-s` on macOS, `ctrl-s` on Windows/Linux, or `:w` in Vim mode). When in a multibuffer, it is often useful to use multiple cursors to edit every file simultaneously. If you want to edit a few instances, you can select them with the mouse (`option-click` on macOS, `alt-click` on Window/Linux) or the keyboard. `cmd-d` on macOS, `ctrl-d` on Windows/Linux, or `gl` in Vim mode will select the next match of the word under the cursor. -When you want to edit all matches you can select them by running the `editor: Select All Matches` command (`cmd-shift-l` on macOS, `ctrl-shift-l` on Windows/Linux, or `g a` in Vim mode). +When you want to edit all matches you can select them by running the {#action editor::SelectAllMatches} command (`cmd-shift-l` on macOS, `ctrl-shift-l` on Windows/Linux, or `g a` in Vim mode). ## Navigating to the Source File -While you can easily edit files in a multibuffer, navigating directly to the source file is often beneficial. You can accomplish this by clicking on any of the divider lines between excerpts or by placing your cursor in an excerpt and executing the `editor: open excerpts` command. It’s key to note that if multiple cursors are being used, the command will open the source file positioned under each cursor within the multibuffer. +While you can easily edit files in a multibuffer, navigating directly to the source file is often beneficial. You can accomplish this by clicking on any of the divider lines between excerpts or by placing your cursor in an excerpt and executing the {#action editor::OpenExcerpts} command. It’s key to note that if multiple cursors are being used, the command will open the source file positioned under each cursor within the multibuffer. Additionally, if you prefer to use the mouse and would like to double-click on an excerpt to open it, you can enable this functionality with the setting: `"double_click_in_multibuffer": "open"`. ## Project search -To start a search run the `pane: Toggle Search` command (`cmd-shift-f` on macOS, `ctrl-shift-f` on Windows/Linux, or `g/` in Vim mode). After the search has completed, the results will be shown in a new multibuffer. There will be one excerpt for each matching line across the whole project. +To start a search run the {#action pane::DeploySearch} command (`cmd-shift-f` on macOS, `ctrl-shift-f` on Windows/Linux, or `g/` in Vim mode). After the search has completed, the results will be shown in a new multibuffer. There will be one excerpt for each matching line across the whole project. ## Diagnostics -If you have a language server installed, the diagnostics pane can show you all errors across your project. You can open it by clicking on the icon in the status bar, or running the `diagnostics: Deploy` command (`cmd-shift-m` on macOS, `ctrl-shift-m` on Windows/Linux, or `:clist` in Vim mode). +If you have a language server installed, the diagnostics pane can show you all errors across your project. You can open it by clicking on the icon in the status bar, or running the {#action diagnostics::Deploy} command (`cmd-shift-m` on macOS, `ctrl-shift-m` on Windows/Linux, or `:clist` in Vim mode). ## Find References -If you have a language server installed, you can find all references to the symbol under the cursor with the `editor: Find References` command (`cmd-click` on macOS, `ctrl-click` on Windows/Linux, or `g A` in Vim mode. +If you have a language server installed, you can find all references to the symbol under the cursor with the {#action editor::FindAllReferences} command (`cmd-click` on macOS, `ctrl-click` on Windows/Linux, or `g A` in Vim mode. -Depending on your language server, commands like `editor: Go To Definition` and `editor: Go To Type Definition` will also open a multibuffer if there are multiple possible definitions. +Depending on your language server, commands like {#action editor::GoToDefinition} and {#action editor::GoToTypeDefinition} will also open a multibuffer if there are multiple possible definitions. diff --git a/docs/src/outline-panel.md b/docs/src/outline-panel.md index 7b31725bf2cec8..aa4c193a5eb243 100644 --- a/docs/src/outline-panel.md +++ b/docs/src/outline-panel.md @@ -5,7 +5,7 @@ description: Navigate code structure with Zed's outline panel. View symbols, jum # Outline Panel -In addition to the modal outline (`cmd-shift-o`), Zed offers an outline panel. The outline panel can be deployed via `cmd-shift-b` (`outline panel: toggle focus` via the command palette), or by clicking the `Outline Panel` button in the status bar. +In addition to the modal outline (`cmd-shift-o`), Zed offers an outline panel. The outline panel can be deployed via `cmd-shift-b` ({#action outline_panel::ToggleFocus} via the command palette), or by clicking the `Outline Panel` button in the status bar. When viewing a "singleton" buffer (i.e., a single file on a tab), the outline panel works similarly to that of the outline modal-it displays the outline of the current buffer's symbols. Each symbol entry shows its type prefix (such as "struct", "fn", "mod", "impl") along with the symbol name, helping you quickly identify what kind of symbol you're looking at. Clicking on an entry allows you to jump to the associated section in the file. The outline view will also automatically scroll to the section associated with the current cursor position within the file. @@ -29,7 +29,7 @@ View a summary of all errors and warnings reported by the language server. ### Find All References -Quickly navigate through all references when using the `editor: find all references` action. +Quickly navigate through all references when using the {#action editor::FindAllReferences} action. ![Using the outline panel while viewing `find all references` multi-buffer](https://zed.dev/img/outline-panel/find-all-references.png) diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 907676c77c896a..6591a47b353db4 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -3153,7 +3153,7 @@ If you wish to exclude certain hosts from using the proxy, set the `NO_PROXY` en ### Performance Profiler -- Description: Collects timing data for foreground and background executor tasks so they can be inspected via the `zed: open performance profiler` action. Enabling this may lead to increased memory usage, hence it's disabled by default for regular builds. +- Description: Collects timing data for foreground and background executor tasks so they can be inspected via the {#action zed::OpenPerformanceProfiler} action. Enabling this may lead to increased memory usage, hence it's disabled by default for regular builds. - Setting: `instrumentation.performance_profiler.enabled` - Default: `false` @@ -5565,7 +5565,7 @@ For example, to use `Nerd Font` as a fallback, add the following to your setting ## Settings Profiles -- Description: Configure any number of settings profiles that are temporarily applied when selected from `settings profile selector: toggle`. +- Description: Configure any number of settings profiles that are temporarily applied when selected from {#action settings_profile_selector::Toggle}. - Setting: `profiles` - Default: `{}` @@ -5607,7 +5607,7 @@ Example: } ``` -To preview and enable a settings profile, open the command palette via {#kb command_palette::Toggle} and search for `settings profile selector: toggle`. +To preview and enable a settings profile, open the command palette via {#kb command_palette::Toggle} and search for {#action settings_profile_selector::Toggle}. ## An example configuration: diff --git a/docs/src/reference/cli.md b/docs/src/reference/cli.md index 788e287c3abe1f..5842bc2e7be4b2 100644 --- a/docs/src/reference/cli.md +++ b/docs/src/reference/cli.md @@ -9,7 +9,7 @@ Use Zed's command-line interface (CLI) to open files and directories, integrate ## Installation -**macOS:** Run the `cli: install` command from the command palette ({#kb command_palette::Toggle}) to install the `zed` CLI to `/usr/local/bin/zed`. +**macOS:** Run the {#action cli::InstallCliBinary} command from the command palette ({#kb command_palette::Toggle}) to install the `zed` CLI to `/usr/local/bin/zed`. **Linux:** The CLI is included with Zed packages. The binary name may vary by distribution (commonly `zed` or `zeditor`). diff --git a/docs/src/repl.md b/docs/src/repl.md index 2e782cb0c14e17..b1704c5b852f7a 100644 --- a/docs/src/repl.md +++ b/docs/src/repl.md @@ -39,21 +39,21 @@ Zed supports running code in multiple languages. To get started, you need to ins - [Julia](#julia) - [Scala (Almond)](#scala) -Once installed, you can start using the REPL in the respective language files, or other places those languages are supported, such as Markdown. If you recently added the kernels, run the `repl: refresh kernelspecs` command to make them available in the editor. +Once installed, you can start using the REPL in the respective language files, or other places those languages are supported, such as Markdown. If you recently added the kernels, run the {#action repl::RefreshKernelspecs} command to make them available in the editor. ## Using the REPL -To start the REPL, open a file with the language you want to use and use the `repl: run` command (defaults to `ctrl-shift-enter` on macOS) to run a block, selection, or line. You can also click on the REPL icon in the toolbar. +To start the REPL, open a file with the language you want to use and use the {#action repl::Run} command (defaults to `ctrl-shift-enter` on macOS) to run a block, selection, or line. You can also click on the REPL icon in the toolbar. -The `repl: run` command will be executed on your selection(s), and the result will be displayed below the selection. +The {#action repl::Run} command will be executed on your selection(s), and the result will be displayed below the selection. -Outputs can be cleared with the `repl: clear outputs` command, or from the REPL menu in the toolbar. +Outputs can be cleared with the {#action repl::ClearOutputs} command, or from the REPL menu in the toolbar. ### Cell mode Zed supports [notebooks as scripts](https://jupytext.readthedocs.io/en/latest/formats-scripts.html) using the `# %%` cell separator in Python and `// %%` in TypeScript. This allows you to write code in a single file and run it as if it were a notebook, cell by cell. -The `repl: run` command will run each block of code between the `# %%` markers as a separate cell. +The {#action repl::Run} command will run each block of code between the `# %%` markers as a separate cell. ```python # %% Cell 1 @@ -201,7 +201,7 @@ If execution is interrupted while an input prompt is active, the prompt automati ## Debugging Kernelspecs -Available kernels are shown via the `repl: sessions` command. To refresh the kernels you can run, use the `repl: refresh kernelspecs` command. +Available kernels are shown via the {#action repl::Sessions} command. To refresh the kernels you can run, use the {#action repl::RefreshKernelspecs} command. If you have `jupyter` installed, you can run `jupyter kernelspec list` to see the available kernels. diff --git a/docs/src/semantic-tokens.md b/docs/src/semantic-tokens.md index d26666ca7e7e60..1afcde8097475b 100644 --- a/docs/src/semantic-tokens.md +++ b/docs/src/semantic-tokens.md @@ -41,7 +41,7 @@ You can configure this globally or per-language: } ``` -> **Note:** Changing the `semantic_tokens` mode may require a language server restart to take effect. Use the `lsp: restart language servers` command from the command palette if highlighting doesn't update immediately. +> **Note:** Changing the `semantic_tokens` mode may require a language server restart to take effect. Use the {#action editor::RestartLanguageServer} command from the command palette if highlighting doesn't update immediately. ## Customizing Token Colors @@ -150,7 +150,7 @@ Zed's default semantic token rules map standard LSP token types to common theme - `class` → `type.class`, `class`, or `type` style (first found) - `comment` with `documentation` modifier → `comment.documentation` or `comment.doc` style -The full default configuration can be shown in Zed with the `zed: show default semantic token rules` command. +The full default configuration can be shown in Zed with the {#action zed::ShowDefaultSemanticTokenRules} command. ## Standard Token Types @@ -184,7 +184,7 @@ For the complete specification, see the [LSP Semantic Tokens documentation](http ## Inspecting Semantic Tokens -To see semantic tokens applied to your code in real-time, use the `dev: open highlights tree view` command from the command palette. This opens a panel showing all highlights (including semantic tokens) for the current buffer, making it easier to understand which tokens are being applied and debug your custom rules. +To see semantic tokens applied to your code in real-time, use the {#action dev::OpenHighlightsTreeView} command from the command palette. This opens a panel showing all highlights (including semantic tokens) for the current buffer, making it easier to understand which tokens are being applied and debug your custom rules. ## Troubleshooting @@ -192,12 +192,12 @@ To see semantic tokens applied to your code in real-time, use the `dev: open hig 1. Ensure `semantic_tokens` is set to `"combined"` or `"full"` for the language 2. Verify the language server supports semantic tokens (not all do) -3. Try restarting the language server with `lsp: restart language servers` -4. Check the LSP logs (`workspace: open lsp log`) for errors +3. Try restarting the language server with {#action editor::RestartLanguageServer} +4. Check the LSP logs ({#action dev::OpenLanguageServerLogs}) for errors ### Colors not updating after changing settings -Changes to `semantic_tokens` mode may require a language server restart. Use `lsp: restart language servers` from the command palette. +Changes to `semantic_tokens` mode may require a language server restart. Use {#action editor::RestartLanguageServer} from the command palette. ### Theme styles not being applied diff --git a/docs/src/tasks.md b/docs/src/tasks.md index 401cef6a4cc667..b1b872e176f68b 100644 --- a/docs/src/tasks.md +++ b/docs/src/tasks.md @@ -62,9 +62,9 @@ Zed supports ways to spawn (and rerun) commands using its integrated [terminal]( ] ``` -There are two actions that drive the workflow of using tasks: `task: spawn` and `task: rerun`. -`task: spawn` opens a modal with all available tasks in the current file. -`task: rerun` reruns the most recently spawned task. You can also rerun tasks from the task modal. +There are two actions that drive the workflow of using tasks: {#action task::Spawn} and {#action task::Rerun}. +{#action task::Spawn} opens a modal with all available tasks in the current file. +{#action task::Rerun} reruns the most recently spawned task. You can also rerun tasks from the task modal. By default, rerunning tasks reuses the same terminal (due to the `"use_new_terminal": false` default) but waits for the previous task to finish before starting (due to the `"allow_concurrent_runs": false` default). @@ -74,8 +74,8 @@ Keep `"use_new_terminal": false` and set `"allow_concurrent_runs": true` to allo Tasks can be defined: -- in the global `tasks.json` file; such tasks are available in all Zed projects you work on. This file is usually located in `~/.config/zed/tasks.json`. You can edit them by using the `zed: open tasks` action. -- in the worktree-specific (local) `.zed/tasks.json` file; such tasks are available only when working on a project with that worktree included. You can edit worktree-specific tasks by using the `zed: open project tasks` action. +- in the global `tasks.json` file; such tasks are available in all Zed projects you work on. This file is usually located in `~/.config/zed/tasks.json`. You can edit them by using the {#action zed::OpenTasks} action. +- in the worktree-specific (local) `.zed/tasks.json` file; such tasks are available only when working on a project with that worktree included. You can edit worktree-specific tasks by using the {#action zed::OpenProjectTasks} action. - on the fly with [oneshot tasks](#oneshot-tasks). These tasks are project-specific and do not persist across sessions. - by language extension. @@ -167,16 +167,16 @@ Set default values to such variables to have such tasks always displayed: ## Oneshot tasks -The same task modal opened via `task: spawn` supports arbitrary bash-like command execution: type a command inside the modal text field, and use `opt-enter` to spawn it. +The same task modal opened via {#action task::Spawn} supports arbitrary bash-like command execution: type a command inside the modal text field, and use `opt-enter` to spawn it. -The task modal persists these ad-hoc commands for the duration of the session, `task: rerun` will also rerun such tasks if they were the last ones spawned. +The task modal persists these ad-hoc commands for the duration of the session, {#action task::Rerun} will also rerun such tasks if they were the last ones spawned. You can also adjust the currently selected task in a modal (`tab` is the default key binding). Doing so will put its command into a prompt that can then be edited & spawned as a oneshot task. ### Ephemeral tasks -You can use the `cmd` modifier when spawning a task via a modal; tasks spawned this way will not have their usage count increased (thus, they will not be respawned with `task: rerun` and they won't have a high rank in the task modal). -The intended use of ephemeral tasks is to stay in the flow with continuous `task: rerun` usage. +You can use the `cmd` modifier when spawning a task via a modal; tasks spawned this way will not have their usage count increased (thus, they will not be respawned with {#action task::Rerun} and they won't have a high rank in the task modal). +The intended use of ephemeral tasks is to stay in the flow with continuous {#action task::Rerun} usage. ### More task rerun control @@ -306,7 +306,7 @@ In doing so, you can change which task is shown in the runnables indicator. ## Keybindings to run tasks bound to runnables -When you have a task definition that is bound to the runnable, you can quickly run it using [Code Actions](https://zed.dev/docs/configuring-languages?#code-actions) that you can trigger either via `editor: Toggle Code Actions` command or by the `cmd-.`/`ctrl-.` shortcut. Your task will be the first in the dropdown. The task will run immediately if there are no additional Code Actions for this line. +When you have a task definition that is bound to the runnable, you can quickly run it using [Code Actions](https://zed.dev/docs/configuring-languages?#code-actions) that you can trigger either via {#action editor::ToggleCodeActions} command or by the `cmd-.`/`ctrl-.` shortcut. Your task will be the first in the dropdown. The task will run immediately if there are no additional Code Actions for this line. ## Running Bash Scripts diff --git a/docs/src/terminal.md b/docs/src/terminal.md index b3c75f338fedaf..e4e876ab2db480 100644 --- a/docs/src/terminal.md +++ b/docs/src/terminal.md @@ -15,14 +15,14 @@ Zed includes a built-in terminal emulator that supports multiple terminal instan | Open new terminal | `Ctrl+~` | `Ctrl+~` | | Open terminal in center | Command palette | Command palette | -You can also open a terminal from the command palette with `terminal panel: toggle` or `workspace: new terminal`. +You can also open a terminal from the command palette with {#action terminal_panel::Toggle} or {#action workspace::NewTerminal}. ### Terminal Panel vs Center Terminal Terminals can open in two locations: - **Terminal Panel** — Docked at the bottom (default), left, or right of the workspace. Toggle with `` Ctrl+` ``. -- **Center Pane** — Opens as a regular tab alongside your files. Use `workspace: new center terminal` from the command palette. +- **Center Pane** — Opens as a regular tab alongside your files. Use {#action workspace::NewCenterTerminal} from the command palette. ## Working with Multiple Terminals diff --git a/docs/src/themes.md b/docs/src/themes.md index d78f96250872ee..347967a3f8d353 100644 --- a/docs/src/themes.md +++ b/docs/src/themes.md @@ -9,13 +9,13 @@ Zed comes with a number of built-in themes, with more themes available as extens ## Selecting a Theme -See what themes are installed and preview them via the Theme Selector, which you can open from the command palette with the `theme selector: toggle` (bound to {#kb theme_selector::Toggle}) action. +See what themes are installed and preview them via the Theme Selector, which you can open from the command palette with the {#action theme_selector::Toggle} (bound to {#kb theme_selector::Toggle}) action. Navigating through the theme list by moving up and down will change the theme in real time and hitting enter will save the selected one to your settings file. ## Installing New Themes -You can find hundreds of different theme options in Zed's extensions store, which you can access via the command palette with `zed: extensions` or the [Zed website](https://zed.dev/extensions?filter=themes). +You can find hundreds of different theme options in Zed's extensions store, which you can access via the command palette with {#action zed::Extensions} or the [Zed website](https://zed.dev/extensions?filter=themes). Many popular themes have been ported to Zed, and if you're struggling to choose one, visit [zed-themes.com](https://zed-themes.com), a third-party gallery with visible previews for many of them. diff --git a/docs/src/update.md b/docs/src/update.md index 1a43bf8d8e1641..8cd3ce3988ccac 100644 --- a/docs/src/update.md +++ b/docs/src/update.md @@ -19,7 +19,7 @@ To check which version of Zed you're using: Open the Command Palette (Cmd+Shift+P on macOS, Ctrl+Shift+P on Linux/Windows). -Type and select `zed: about`. A modal will appear with your version information. +Type and select {#action zed::About}. A modal will appear with your version information. ## How to control update behavior diff --git a/docs/src/vim.md b/docs/src/vim.md index e53e37fb31235c..1f777537ba8f1b 100644 --- a/docs/src/vim.md +++ b/docs/src/vim.md @@ -30,7 +30,7 @@ There are four types of features in vim mode that use Zed's core functionality, When you first open Zed, you'll see a checkbox on the welcome screen that allows you to enable vim mode. -If you missed this, you can toggle vim mode on or off anytime by opening the command palette and using the workspace command `toggle vim mode`. +If you missed this, you can toggle vim mode on or off anytime by opening the command palette and using the workspace command {#action workspace::ToggleVimMode}. > **Note**: This command toggles the following property in your user settings: > diff --git a/docs/src/worktree-trust.md b/docs/src/worktree-trust.md index 35c25cda0e2c19..4d5a18d7b201c4 100644 --- a/docs/src/worktree-trust.md +++ b/docs/src/worktree-trust.md @@ -50,7 +50,7 @@ Zed has multiple layers of trust, based on the requests, from the least to most - "single file worktree" After opening an empty Zed window, you can open a single file. You can also open a file outside the current directory after opening a directory. -A common example is `zed: open settings file`, which may start a language server for that file and create a new single-file worktree. +A common example is {#action zed::OpenSettingsFile}, which may start a language server for that file and create a new single-file worktree. Spawning a language server presents a risk should the language server experience a supply-chain attack; therefore, Zed restricts that by default. Each single file worktree requires a separate trust grant, unless the directory containing it is trusted or all worktrees are trusted. From 0a52f80824a2f1e9b48dbdc615d63453eaaaf1f9 Mon Sep 17 00:00:00 2001 From: Bruno Moreira Date: Thu, 7 May 2026 05:17:16 -0300 Subject: [PATCH 03/33] acp_thread: Clear running_turn when prompt task drops tx (#55562) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AcpThread::status` is purely `running_turn.is_some()`. The cleanup that takes `running_turn` sat below the early-return guard that fires when the prompt response oneshot resolves to `Err(Cancelled)` (the inner `send_task` was dropped before `tx.send`). Any code path that drops the in-flight `send_task` therefore left the panel stuck in `Generating`. Reordered so cleanup runs before the dropped-tx guard; the same-turn invariant is preserved. Related to #47928 (partial — that issue also has an upstream `claude-agent-acp` component this PR does not address). Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed agent panel staying in a generating state when the underlying prompt task was cancelled before completing --- crates/acp_thread/src/acp_thread.rs | 72 +++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 2c448d343075b6..769131a8e0d276 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -2294,10 +2294,6 @@ impl AcpThread { this.project .update(cx, |project, cx| project.set_agent_location(None, cx)); } - let Ok(response) = response else { - // tx dropped, just return - return Ok(None); - }; let is_same_turn = this .running_turn @@ -2306,11 +2302,18 @@ impl AcpThread { // If the user submitted a follow up message, running_turn might // already point to a different turn. Therefore we only want to - // take the task if it's the same turn. + // take the task if it's the same turn. We do this before the + // dropped-tx guard below so the panel exits its generating + // state even when the send_task is cancelled before tx.send(). if is_same_turn { this.running_turn.take(); } + let Ok(response) = response else { + // tx dropped, just return + return Ok(None); + }; + match response { Ok(r) => { Self::flush_streaming_text(&mut this.streaming_text_buffer, cx); @@ -5517,4 +5520,63 @@ mod tests { ); }); } + + /// Regression test: if the inner send_task is cancelled before it can + /// fire `tx.send(...)` (e.g. because the underlying future was dropped), + /// the outer task observes `rx.await` returning `Err(Cancelled)` and + /// must still clear `running_turn` so the panel transitions out of + /// `Generating`. Without this, the agent thread is wedged in the + /// loading state until Zed restarts. + #[gpui::test] + async fn test_running_turn_cleared_when_send_task_dropped(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + + // Handler hangs forever so the spawn at run_turn is parked inside + // `f(this, cx).await` with `tx` still alive but unsent. + let connection = Rc::new(FakeAgentConnection::new().on_user_message( + |_params, _thread, _cx| { + async move { futures::future::pending::>().await } + .boxed_local() + }, + )); + + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap(); + + let request = thread.update(cx, |thread, cx| thread.send_raw("hello", cx)); + cx.run_until_parked(); + + assert_eq!( + thread.read_with(cx, |t, _| t.status()), + ThreadStatus::Generating, + "thread should be generating while the handler is parked" + ); + + // Replace the in-flight send_task with a no-op. Dropping the original + // Task cancels its inner future, which drops `tx` without ever calling + // `tx.send(...)`. This mirrors the production scenario where the + // send_task future is cancelled before completion. + thread.update(cx, |thread, _| { + thread.running_turn.as_mut().unwrap().send_task = Task::ready(()); + }); + + let result = request.await; + assert!( + matches!(result, Ok(None)), + "outer task should resolve to Ok(None) on dropped tx, got {result:?}" + ); + + assert_eq!( + thread.read_with(cx, |t, _| t.status()), + ThreadStatus::Idle, + "running_turn must be cleared even when tx was dropped without send" + ); + } } From 7fcc4ba3438e3ea8a2d1c436090a88fc8c853b6c Mon Sep 17 00:00:00 2001 From: YangChengxxyy <45156288+YangChengxxyy@users.noreply.github.com> Date: Thu, 7 May 2026 16:58:17 +0800 Subject: [PATCH 04/33] =?UTF-8?q?eslint:=20Fix=20`workspaceFolder.uri`=20s?= =?UTF-8?q?ent=20as=20raw=20path=20instead=20of=20`file:/=E2=80=A6=20(#543?= =?UTF-8?q?83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …/` URI The ESLint adapter was sending `workspaceFolder.uri` as a raw filesystem path (e.g. `/Users/foo/project`) instead of a proper `file://` URI (e.g. `file:///Users/foo/project`). This caused the vscode-eslint server's `workingDirectory: { mode: "auto" }` to fail when resolving the workspace root, falling back to the linted file's directory as the working directory. As a result, `eslint-import-resolver-typescript` could not locate `tsconfig.json`, breaking path alias resolution for rules like `import/order` — producing different lint results compared to VS Code. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A or Added/Fixed/Improved ... --- crates/languages/src/eslint.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/languages/src/eslint.rs b/crates/languages/src/eslint.rs index 7ef55c64ef1b35..e9b94380191731 100644 --- a/crates/languages/src/eslint.rs +++ b/crates/languages/src/eslint.rs @@ -254,7 +254,9 @@ impl LspAdapter for EsLintLspAdapter { "mode": "auto" }, "workspaceFolder": { - "uri": worktree_root, + "uri": Uri::from_file_path(worktree_root) + .map(|uri| uri.as_str().to_owned()) + .unwrap_or_default(), "name": worktree_root.file_name() .unwrap_or(worktree_root.as_os_str()) .to_string_lossy(), From 42017bcad2631b6bafef52942899b4989d2c0c72 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 7 May 2026 10:59:28 +0200 Subject: [PATCH 05/33] agent: Handle out of order old_text/new_text in edit file tool (#55894) In the case where the model would respond with `new_text` before `old_text`, we would just emit an empty `old_text`, because the parsing layer was operating under the assumption that `old_text` occurs before `new_text`. We now hold back new text chunks if we receive them first, and only emit them once old_text is complete. In addition to that we also need to handle the case where the first chunk contains `old_text` and `new_text`. In that case we don't know which one of the two fields have finished streaming, since we can't rely on the ordering anymore. Therefore we hold back all events until we receive the full edit, and emit a single OldTextChunk (done = true) and a single NewTextChunk (done = true) Closes #55398 Release Notes: - agent: Fixed an issue where editing would sometimes fail for specific models (Deepseek v4) --- crates/agent/src/tools/edit_file_tool.rs | 118 +++++++++ .../tools/edit_session/streaming_parser.rs | 240 ++++++++++++++---- 2 files changed, 302 insertions(+), 56 deletions(-) diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index 1061d5a5b7e4cc..31eb788dfa3c6d 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -753,6 +753,15 @@ mod tests { // Edit 2 appears — edit 1 is now complete and should be applied sender.send_partial(json!({ "path": "root/file.txt", + "edits": [ + {"old_text": "aaa", "new_text": "AAA"}, + {"old_text": "ccc"} + ] + })); + cx.run_until_parked(); + sender.send_partial(json!({ + "path": "root/file.txt", + "mode": "edit", "edits": [ {"old_text": "aaa", "new_text": "AAA"}, {"old_text": "ccc", "new_text": "CCC"} @@ -774,6 +783,16 @@ mod tests { // Edit 3 appears — edit 2 is now complete and should be applied sender.send_partial(json!({ "path": "root/file.txt", + "edits": [ + {"old_text": "aaa", "new_text": "AAA"}, + {"old_text": "ccc", "new_text": "CCC"}, + {"old_text": "eee"} + ] + })); + cx.run_until_parked(); + sender.send_partial(json!({ + "path": "root/file.txt", + "mode": "edit", "edits": [ {"old_text": "aaa", "new_text": "AAA"}, {"old_text": "ccc", "new_text": "CCC"}, @@ -909,6 +928,12 @@ mod tests { })); cx.run_until_parked(); + sender.send_partial(json!({ + "path": "root/file.txt", + "edits": [{"old_text": "hello world"}] + })); + cx.run_until_parked(); + sender.send_partial(json!({ "path": "root/file.txt", "edits": [{"old_text": "hello world", "new_text": "goodbye world"}] @@ -2135,6 +2160,99 @@ mod tests { assert_eq!(new_text, "new_content"); } + #[gpui::test] + async fn test_streaming_edit_file_tool_new_and_old_text_appear_together( + cx: &mut TestAppContext, + ) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "old_content"})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + sender.send_partial(json!({ + "mode": "edit", + "path": "root/file.txt" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "mode": "edit", + "path": "root/file.txt", + "edits": [{"new_text": "new_content", "old_text": "old"}] + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "mode": "edit", + "path": "root/file.txt", + "edits": [{"new_text": "new_content", "old_text": "old_content"}] + })); + cx.run_until_parked(); + + sender.send_full(json!({ + "mode": "edit", + "path": "root/file.txt", + "edits": [{"new_text": "new_content", "old_text": "old_content"}] + })); + cx.run_until_parked(); + + let result = task.await; + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "new_content"); + } + + #[gpui::test] + async fn test_streaming_edit_file_tool_new_text_before_old_text(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "old_content"})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + sender.send_partial(json!({ + "mode": "edit", + "path": "root/file.txt" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "mode": "edit", + "path": "root/file.txt", + "edits": [{"new_text": "new_content"}] + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "mode": "edit", + "path": "root/file.txt", + "edits": [{"new_text": "new_content", "old_text": ""}] + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "mode": "edit", + "path": "root/file.txt", + "edits": [{"new_text": "new_content", "old_text": "old"}] + })); + cx.run_until_parked(); + + sender.send_full(json!({ + "mode": "edit", + "path": "root/file.txt", + "edits": [{"new_text": "new_content", "old_text": "old_content"}] + })); + cx.run_until_parked(); + + let result = task.await; + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + 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#" diff --git a/crates/agent/src/tools/edit_session/streaming_parser.rs b/crates/agent/src/tools/edit_session/streaming_parser.rs index a976b08b004771..3961edf564ccfc 100644 --- a/crates/agent/src/tools/edit_session/streaming_parser.rs +++ b/crates/agent/src/tools/edit_session/streaming_parser.rs @@ -33,6 +33,8 @@ struct EditStreamState { old_text_done: bool, new_text_emitted_len: usize, new_text_done: bool, + hold_until_complete: bool, + buffer_new_text_until_old_text_done: bool, } /// Converts incrementally-growing tool call JSON into a stream of chunk events. @@ -68,7 +70,15 @@ impl StreamingParser { for (index, partial) in edits.iter().enumerate() { if index >= self.edit_states.len() { // A new edit appeared — finalize the previous one if there was one. - if let Some(previous) = self.finalize_previous_edit(index) { + if let Some(previous) = self.finalize_previous_edit( + index, + edits + .get(index.saturating_sub(1)) + .and_then(|edit| edit.old_text.as_deref()), + edits + .get(index.saturating_sub(1)) + .and_then(|edit| edit.new_text.as_deref()), + ) { events.extend(previous); } self.edit_states.push(EditStreamState::default()); @@ -76,12 +86,33 @@ impl StreamingParser { let state = &mut self.edit_states[index]; + if state.old_text_emitted_len == 0 + && state.new_text_emitted_len == 0 + && !state.old_text_done + && partial.new_text.is_some() + && !state.buffer_new_text_until_old_text_done + { + if partial + .old_text + .as_ref() + .is_some_and(|old_text| !old_text.is_empty()) + { + state.hold_until_complete = true; + } else { + state.buffer_new_text_until_old_text_done = true; + } + } + + if state.hold_until_complete { + continue; + } + // Process old_text changes. if let Some(old_text) = &partial.old_text && !state.old_text_done { - if partial.new_text.is_some() { - // new_text appeared, so old_text is done — emit everything. + if partial.new_text.is_some() && !state.buffer_new_text_until_old_text_done { + // new_text appeared after old_text, so old_text is done — emit everything. let start = state.old_text_emitted_len.min(old_text.len()); let chunk = normalize_done_chunk(old_text[start..].to_string()); state.old_text_done = true; @@ -108,6 +139,7 @@ impl StreamingParser { // Process new_text changes. if let Some(new_text) = &partial.new_text + && state.old_text_done && !state.new_text_done { let safe_end = safe_emit_end_for_edit_text(new_text); @@ -157,7 +189,15 @@ impl StreamingParser { for (index, edit) in edits.iter().enumerate() { if index >= self.edit_states.len() { // This edit was never seen in partials — emit it fully. - if let Some(previous) = self.finalize_previous_edit(index) { + if let Some(previous) = self.finalize_previous_edit( + index, + edits + .get(index.saturating_sub(1)) + .map(|edit| edit.old_text.as_str()), + edits + .get(index.saturating_sub(1)) + .map(|edit| edit.new_text.as_str()), + ) { events.extend(previous); } self.edit_states.push(EditStreamState::default()); @@ -165,6 +205,26 @@ impl StreamingParser { let state = &mut self.edit_states[index]; + if state.hold_until_complete { + state.old_text_done = true; + state.old_text_emitted_len = edit.old_text.len(); + state.new_text_done = true; + state.new_text_emitted_len = edit.new_text.len(); + state.hold_until_complete = false; + state.buffer_new_text_until_old_text_done = false; + events.push(EditEvent::OldTextChunk { + edit_index: index, + chunk: normalize_done_chunk(edit.old_text.clone()), + done: true, + }); + events.push(EditEvent::NewTextChunk { + edit_index: index, + chunk: normalize_done_chunk(edit.new_text.clone()), + done: true, + }); + continue; + } + if !state.old_text_done { let start = state.old_text_emitted_len.min(edit.old_text.len()); let chunk = normalize_done_chunk(edit.old_text[start..].to_string()); @@ -209,7 +269,12 @@ impl StreamingParser { /// When a new edit appears at `index`, finalize the edit at `index - 1` /// by emitting a `NewTextChunk { done: true }` if it hasn't been finalized. - fn finalize_previous_edit(&mut self, new_index: usize) -> Option> { + fn finalize_previous_edit( + &mut self, + new_index: usize, + old_text: Option<&str>, + new_text: Option<&str>, + ) -> Option> { if new_index == 0 || self.edit_states.is_empty() { return None; } @@ -222,22 +287,49 @@ impl StreamingParser { let state = &mut self.edit_states[previous_index]; let mut events = SmallVec::new(); - // If old_text was never finalized, finalize it now with an empty done chunk. + if state.hold_until_complete { + let old_text = old_text.unwrap_or_default(); + let new_text = new_text.unwrap_or_default(); + state.old_text_done = true; + state.old_text_emitted_len = old_text.len(); + state.new_text_done = true; + state.new_text_emitted_len = new_text.len(); + state.hold_until_complete = false; + state.buffer_new_text_until_old_text_done = false; + events.push(EditEvent::OldTextChunk { + edit_index: previous_index, + chunk: normalize_done_chunk(old_text.to_string()), + done: true, + }); + events.push(EditEvent::NewTextChunk { + edit_index: previous_index, + chunk: normalize_done_chunk(new_text.to_string()), + done: true, + }); + return Some(events); + } + if !state.old_text_done { + let old_text = old_text.unwrap_or_default(); + let start = state.old_text_emitted_len.min(old_text.len()); state.old_text_done = true; + state.old_text_emitted_len = old_text.len(); events.push(EditEvent::OldTextChunk { edit_index: previous_index, - chunk: String::new(), + chunk: normalize_done_chunk(old_text[start..].to_string()), done: true, }); } - // Emit a done event for new_text if not already finalized. if !state.new_text_done { + let new_text = new_text.unwrap_or_default(); + let start = state.new_text_emitted_len.min(new_text.len()); state.new_text_done = true; + state.new_text_emitted_len = new_text.len(); + state.buffer_new_text_until_old_text_done = false; events.push(EditEvent::NewTextChunk { edit_index: previous_index, - chunk: String::new(), + chunk: normalize_done_chunk(new_text[start..].to_string()), done: true, }); } @@ -279,6 +371,43 @@ fn normalize_done_chunk(mut chunk: String) -> String { mod tests { use super::*; + #[test] + fn test_first_edit_with_new_text_in_first_chunk_is_held_until_finalize() { + let mut parser = StreamingParser::default(); + + let events = parser.push_edits(&[PartialEdit { + old_text: Some("old".into()), + new_text: Some("new".into()), + }]); + assert!(events.is_empty()); + + let events = parser.push_edits(&[PartialEdit { + old_text: Some("old text".into()), + new_text: Some("new text".into()), + }]); + assert!(events.is_empty()); + + let events = parser.finalize_edits(&[Edit { + old_text: "old text".into(), + new_text: "new text".into(), + }]); + assert_eq!( + events.as_slice(), + &[ + EditEvent::OldTextChunk { + edit_index: 0, + chunk: "old text".into(), + done: true, + }, + EditEvent::NewTextChunk { + edit_index: 0, + chunk: "new text".into(), + done: true, + }, + ] + ); + } + #[test] fn test_single_edit_streamed_incrementally() { let mut parser = StreamingParser::default(); @@ -393,6 +522,12 @@ mod tests { old_text: Some("before\n".into()), new_text: Some("after\n".into()), }]); + assert!(events.is_empty()); + + let events = parser.finalize_edits(&[Edit { + old_text: "before\n".into(), + new_text: "after\n".into(), + }]); assert_eq!( events.as_slice(), &[ @@ -404,23 +539,10 @@ mod tests { EditEvent::NewTextChunk { edit_index: 0, chunk: "after".into(), - done: false, + done: true, }, ] ); - - let events = parser.finalize_edits(&[Edit { - old_text: "before\n".into(), - new_text: "after\n".into(), - }]); - assert_eq!( - events.as_slice(), - &[EditEvent::NewTextChunk { - edit_index: 0, - chunk: "".into(), - done: true, - }] - ); } #[test] @@ -731,13 +853,31 @@ mod tests { } #[test] - fn test_empty_old_text_with_new_text() { + fn test_new_text_before_old_text_buffers_new_text_but_streams_old_text() { let mut parser = StreamingParser::default(); - // old_text is empty, new_text appears immediately let events = parser.push_edits(&[PartialEdit { - old_text: Some("".into()), - new_text: Some("inserted".into()), + old_text: None, + new_text: Some("new".into()), + }]); + assert!(events.is_empty()); + + let events = parser.push_edits(&[PartialEdit { + old_text: Some("old".into()), + new_text: Some("new".into()), + }]); + assert_eq!( + events.as_slice(), + &[EditEvent::OldTextChunk { + edit_index: 0, + chunk: "old".into(), + done: false, + }] + ); + + let events = parser.finalize_edits(&[Edit { + old_text: "old".into(), + new_text: "new".into(), }]); assert_eq!( events.as_slice(), @@ -749,8 +889,8 @@ mod tests { }, EditEvent::NewTextChunk { edit_index: 0, - chunk: "inserted".into(), - done: false, + chunk: "new".into(), + done: true, }, ] ); @@ -794,13 +934,17 @@ mod tests { }, ]); - // Should finalize edit 1 (index=1) and start edit 2 (index=2) assert_eq!( events.as_slice(), &[ + EditEvent::OldTextChunk { + edit_index: 1, + chunk: "b".into(), + done: true, + }, EditEvent::NewTextChunk { edit_index: 1, - chunk: "".into(), + chunk: "B".into(), done: true, }, EditEvent::OldTextChunk { @@ -875,49 +1019,33 @@ mod tests { } #[test] - fn test_finalize_with_partially_seen_new_text() { + fn test_repeated_pushes_with_no_change() { let mut parser = StreamingParser::default(); - parser.push_edits(&[PartialEdit { - old_text: Some("old".into()), - new_text: Some("partial".into()), - }]); - - let events = parser.finalize_edits(&[Edit { - old_text: "old".into(), - new_text: "partial new text".into(), + let events = parser.push_edits(&[PartialEdit { + old_text: Some("stable".into()), + new_text: None, }]); assert_eq!( events.as_slice(), - &[EditEvent::NewTextChunk { + &[EditEvent::OldTextChunk { edit_index: 0, - chunk: " new text".into(), - done: true, + chunk: "stable".into(), + done: false, }] ); - } - - #[test] - fn test_repeated_pushes_with_no_change() { - let mut parser = StreamingParser::default(); - - let events = parser.push_edits(&[PartialEdit { - old_text: Some("stable".into()), - new_text: Some("also stable".into()), - }]); - assert_eq!(events.len(), 2); // old done + new chunk // Push the exact same data again let events = parser.push_edits(&[PartialEdit { old_text: Some("stable".into()), - new_text: Some("also stable".into()), + new_text: None, }]); assert!(events.is_empty()); // And again let events = parser.push_edits(&[PartialEdit { old_text: Some("stable".into()), - new_text: Some("also stable".into()), + new_text: None, }]); assert!(events.is_empty()); } From b3a67c988f9d28a009086952d46fc8c977ad028c Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 7 May 2026 11:31:37 +0200 Subject: [PATCH 06/33] markdown_preview: Implement reload (#56016) If you implement can_save, you need to also support reload. Fix a bug introduced in #53236 Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed missing reload implementation for markdown preview. --- .../src/markdown_preview_view.rs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 76b46a520d5391..333a1b2a2b3cea 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -975,6 +975,16 @@ impl Item for MarkdownPreviewView { .unwrap_or_else(|| Task::ready(Ok(()))) } + fn reload( + &mut self, + _project: Entity, + _window: &mut Window, + _cx: &mut Context, + ) -> Task> { + // The preview is not the owner of the source editor's buffer, so force-closing it should not discard editor changes. + Task::ready(Ok(())) + } + fn to_item_events(_event: &Self::Event, _f: &mut dyn FnMut(workspace::item::ItemEvent)) {} fn buffer_kind(&self, _cx: &App) -> ItemBufferKind { @@ -1354,6 +1364,92 @@ mod tests { ); } + #[gpui::test] + async fn force_closing_preview_preserves_source_editor_changes(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/dir"), + json!({ + "todo.md": "- [ ] Finish work\n" + }), + ) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir/todo.md"))], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + let (preview, editor) = multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + let editor: Entity = workspace + .read(cx) + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + .unwrap(); + + let preview = workspace.update(cx, |workspace, cx| { + let preview = MarkdownPreviewView::create_markdown_view( + workspace, + editor.clone(), + window, + cx, + ); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item(Box::new(preview.clone()), true, true, None, window, cx) + }); + preview + }); + + (preview, editor) + }) + .unwrap(); + cx.run_until_parked(); + + multi_workspace + .update(cx, |_, window, cx| { + let view_handle = preview.downgrade(); + assert!(preview.read(cx).focus_handle.contains_focused(window, cx)); + MarkdownPreviewView::apply_checkbox_toggle_to_editor(&editor, 2..5, true, cx); + MarkdownPreviewView::refresh_preview(view_handle, window, cx); + }) + .unwrap(); + + assert_eq!( + editor.read_with(cx, |editor, cx| editor.buffer().read(cx).read(cx).text()), + "- [x] Finish work\n" + ); + + let close_task = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.close_item_by_id(preview.entity_id(), SaveIntent::Skip, window, cx) + }) + }) + }) + .unwrap(); + + close_task.await.unwrap(); + cx.run_until_parked(); + + assert_eq!( + editor.read_with(cx, |editor, cx| editor.buffer().read(cx).read(cx).text()), + "- [x] Finish work\n" + ); + } + fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); From c6e9a95eba5a8635f9a237ce5159d2433f4e1e5f Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 7 May 2026 11:56:04 +0200 Subject: [PATCH 07/33] x_ai: Update models list (#55931) Updates the list of models being available based on https://docs.x.ai/developers/models From the xAI site: image Closes #55883 Release Notes: - agent: Added support for grok-4.3, grok-4.2 and removed deprecated xAI models --- crates/x_ai/src/x_ai.rs | 147 +++++++--------------------------------- 1 file changed, 26 insertions(+), 121 deletions(-) diff --git a/crates/x_ai/src/x_ai.rs b/crates/x_ai/src/x_ai.rs index afa7d62aa3c991..7ba13d835295a2 100644 --- a/crates/x_ai/src/x_ai.rs +++ b/crates/x_ai/src/x_ai.rs @@ -7,42 +7,13 @@ pub const XAI_API_URL: &str = "https://api.x.ai/v1"; #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)] pub enum Model { - #[serde(rename = "grok-2-vision-latest")] - Grok2Vision, #[default] - #[serde(rename = "grok-3-latest")] - Grok3, - #[serde(rename = "grok-3-mini-latest")] - Grok3Mini, - #[serde(rename = "grok-3-fast-latest")] - Grok3Fast, - #[serde(rename = "grok-3-mini-fast-latest")] - Grok3MiniFast, - #[serde(rename = "grok-4", alias = "grok-4-latest")] - Grok4, - #[serde( - rename = "grok-4-fast-reasoning", - alias = "grok-4-fast-reasoning-latest" - )] - Grok4FastReasoning, - #[serde( - rename = "grok-4-fast-non-reasoning", - alias = "grok-4-fast-non-reasoning-latest" - )] - Grok4FastNonReasoning, - #[serde( - rename = "grok-4-1-fast-non-reasoning", - alias = "grok-4-1-fast-non-reasoning-latest" - )] - Grok41FastNonReasoning, - #[serde( - rename = "grok-4-1-fast-reasoning", - alias = "grok-4-1-fast-reasoning-latest", - alias = "grok-4-1-fast" - )] - Grok41FastReasoning, - #[serde(rename = "grok-code-fast-1", alias = "grok-code-fast-1-0825")] - GrokCodeFast1, + #[serde(rename = "grok-4.3", alias = "grok-4.3-latest")] + Grok43, + #[serde(rename = "grok-4.20-0309-reasoning")] + Grok420Reasoning, + #[serde(rename = "grok-4.20-0309-non-reasoning")] + Grok420NonReasoning, #[serde(rename = "custom")] Custom { name: String, @@ -59,57 +30,32 @@ pub enum Model { impl Model { pub fn default_fast() -> Self { - Self::Grok3Fast + Self::Grok43 } pub fn from_id(id: &str) -> Result { match id { - "grok-4" => Ok(Self::Grok4), - "grok-4-fast-reasoning" => Ok(Self::Grok4FastReasoning), - "grok-4-fast-non-reasoning" => Ok(Self::Grok4FastNonReasoning), - "grok-4-1-fast-non-reasoning" => Ok(Self::Grok41FastNonReasoning), - "grok-4-1-fast-reasoning" => Ok(Self::Grok41FastReasoning), - "grok-4-1-fast" => Ok(Self::Grok41FastReasoning), - "grok-2-vision" => Ok(Self::Grok2Vision), - "grok-3" => Ok(Self::Grok3), - "grok-3-mini" => Ok(Self::Grok3Mini), - "grok-3-fast" => Ok(Self::Grok3Fast), - "grok-3-mini-fast" => Ok(Self::Grok3MiniFast), - "grok-code-fast-1" => Ok(Self::GrokCodeFast1), + "grok-4.3" => Ok(Self::Grok43), + "grok-4.20-0309-reasoning" => Ok(Self::Grok420Reasoning), + "grok-4.20-0309-non-reasoning" => Ok(Self::Grok420NonReasoning), _ => anyhow::bail!("invalid model id '{id}'"), } } pub fn id(&self) -> &str { match self { - Self::Grok2Vision => "grok-2-vision", - Self::Grok3 => "grok-3", - Self::Grok3Mini => "grok-3-mini", - Self::Grok3Fast => "grok-3-fast", - Self::Grok3MiniFast => "grok-3-mini-fast", - Self::Grok4 => "grok-4", - Self::Grok4FastReasoning => "grok-4-fast-reasoning", - Self::Grok4FastNonReasoning => "grok-4-fast-non-reasoning", - Self::Grok41FastNonReasoning => "grok-4-1-fast-non-reasoning", - Self::Grok41FastReasoning => "grok-4-1-fast-reasoning", - Self::GrokCodeFast1 => "grok-code-fast-1", + Self::Grok43 => "grok-4.3", + Self::Grok420Reasoning => "grok-4.20-0309-reasoning", + Self::Grok420NonReasoning => "grok-4.20-0309-non-reasoning", Self::Custom { name, .. } => name, } } pub fn display_name(&self) -> &str { match self { - Self::Grok2Vision => "Grok 2 Vision", - Self::Grok3 => "Grok 3", - Self::Grok3Mini => "Grok 3 Mini", - Self::Grok3Fast => "Grok 3 Fast", - Self::Grok3MiniFast => "Grok 3 Mini Fast", - Self::Grok4 => "Grok 4", - Self::Grok4FastReasoning => "Grok 4 Fast", - Self::Grok4FastNonReasoning => "Grok 4 Fast (Non-Reasoning)", - Self::Grok41FastNonReasoning => "Grok 4.1 Fast (Non-Reasoning)", - Self::Grok41FastReasoning => "Grok 4.1 Fast", - Self::GrokCodeFast1 => "Grok Code Fast 1", + Self::Grok43 => "Grok 4.3", + Self::Grok420Reasoning => "Grok 4.20 Reasoning", + Self::Grok420NonReasoning => "Grok 4.20 (Non-Reasoning)", Self::Custom { name, display_name, .. } => display_name.as_ref().unwrap_or(name), @@ -118,27 +64,15 @@ impl Model { pub fn max_token_count(&self) -> u64 { match self { - Self::Grok3 | Self::Grok3Mini | Self::Grok3Fast | Self::Grok3MiniFast => 131_072, - Self::Grok4 | Self::GrokCodeFast1 => 256_000, - Self::Grok4FastReasoning - | Self::Grok4FastNonReasoning - | Self::Grok41FastNonReasoning - | Self::Grok41FastReasoning => 2_000_000, - Self::Grok2Vision => 8_192, + Self::Grok43 => 1_000_000, + Self::Grok420Reasoning | Self::Grok420NonReasoning => 2_000_000, Self::Custom { max_tokens, .. } => *max_tokens, } } pub fn max_output_tokens(&self) -> Option { match self { - Self::Grok3 | Self::Grok3Mini | Self::Grok3Fast | Self::Grok3MiniFast => Some(8_192), - Self::Grok4 - | Self::Grok4FastReasoning - | Self::Grok4FastNonReasoning - | Self::Grok41FastNonReasoning - | Self::Grok41FastReasoning - | Self::GrokCodeFast1 => Some(64_000), - Self::Grok2Vision => Some(4_096), + Self::Grok43 | Self::Grok420Reasoning | Self::Grok420NonReasoning => Some(64_000), Self::Custom { max_output_tokens, .. } => *max_output_tokens, @@ -147,33 +81,19 @@ impl Model { pub fn supports_parallel_tool_calls(&self) -> bool { match self { - Self::Grok2Vision - | Self::Grok3 - | Self::Grok3Mini - | Self::Grok3Fast - | Self::Grok3MiniFast - | Self::Grok4 - | Self::Grok4FastReasoning - | Self::Grok4FastNonReasoning - | Self::Grok41FastNonReasoning - | Self::Grok41FastReasoning => true, + Self::Grok43 | Self::Grok420Reasoning | Self::Grok420NonReasoning => true, Self::Custom { parallel_tool_calls: Some(support), .. } => *support, - Self::GrokCodeFast1 | Model::Custom { .. } => false, + Model::Custom { .. } => false, } } pub fn requires_json_schema_subset(&self) -> bool { match self { - Self::Grok4 - | Self::Grok4FastReasoning - | Self::Grok4FastNonReasoning - | Self::Grok41FastNonReasoning - | Self::Grok41FastReasoning - | Self::GrokCodeFast1 => true, - _ => false, + Self::Grok43 | Self::Grok420Reasoning | Self::Grok420NonReasoning => true, + Self::Custom { .. } => false, } } @@ -183,17 +103,7 @@ impl Model { pub fn supports_tool(&self) -> bool { match self { - Self::Grok2Vision - | Self::Grok3 - | Self::Grok3Mini - | Self::Grok3Fast - | Self::Grok3MiniFast - | Self::Grok4 - | Self::Grok4FastReasoning - | Self::Grok4FastNonReasoning - | Self::Grok41FastNonReasoning - | Self::Grok41FastReasoning - | Self::GrokCodeFast1 => true, + Self::Grok43 | Self::Grok420Reasoning | Self::Grok420NonReasoning => true, Self::Custom { supports_tools: Some(support), .. @@ -204,17 +114,12 @@ impl Model { pub fn supports_images(&self) -> bool { match self { - Self::Grok2Vision - | Self::Grok4 - | Self::Grok4FastReasoning - | Self::Grok4FastNonReasoning - | Self::Grok41FastNonReasoning - | Self::Grok41FastReasoning => true, + Self::Grok43 | Self::Grok420Reasoning | Self::Grok420NonReasoning => true, Self::Custom { supports_images: Some(support), .. } => *support, - _ => false, + Self::Custom { .. } => false, } } } From 7d19e899889fc2e4a7999e9ce77a4bd438e3dc3b Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 7 May 2026 12:22:52 +0200 Subject: [PATCH 08/33] Fix DirectX atlas panic after GPU device recovery (#55878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem A Sentry-reported crash on Windows (Intel Iris Xe Graphics, v1.0.1): ``` index out of bounds: the len is 1 but the index is 1 ``` panicking at `DirectXAtlasState::texture` in [`crates/gpui_windows/src/directx_atlas.rs`](https://github.com/zed-industries/zed/blob/main/crates/gpui_windows/src/directx_atlas.rs): ```rust AtlasTextureKind::Subpixel => { &self.subpixel_textures[id.index as usize].as_ref().unwrap() } ``` ## Root cause After a GPU device-lost recovery, GPUI's view cache replays stale `AtlasTile` references from the previous frame's `paint_operations` via `Scene::replay`. 1. **Atlas grows past one texture.** A long enough session pushes `subpixel_textures.textures.len() ≥ 2` (easy on Iris Xe at the default 1024×1024 atlas size). Top-level views in Zed use `cached(...)`, so their `AnyViewState.paint_range` records into `rendered_frame.scene.paint_operations`, referencing both index `0` and index `1`. 2. **Device lost.** `handle_device_lost` clears every `AtlasTextureList` (`textures.len() == 0`) and `tiles_by_key`, then sets `skip_draws = true`. 3. **`WM_GPUI_FORCE_UPDATE_WINDOW` arrives.** `mark_drawable()` flips `skip_draws` back to `false` and `request_frame` runs with `force_render: true`. 4. **The cache hit.** Inside `Window::draw`, `AnyView::prepaint`'s cache check (`!dirty_views.contains(...) && !window.refreshing`) succeeds for every cached view because the recovery doesn't touch invalidator state and `force_render` doesn't propagate into `Window`. `AnyView::paint` calls `window.reuse_paint` → `Scene::replay` → `primitive.clone()`, which (since `SubpixelSprite`/`AtlasTile` are `Copy`) verbatim copies a `Primitive::SubpixelSprite { tile: { texture_id: { index: 1, ... }, ... } }` into `next_frame.scene`. 5. **Atlas regrows to one.** Dirty/uncached parts of the same frame (caret, animations, anything that called `cx.notify`) fall through to `paint_glyph` → `get_or_insert_with` → `push_texture`, growing `subpixel_textures.textures` from `0` to **`1`** with index `0` valid. 6. **Panic.** After `mem::swap`, `rendered_frame.scene` contains a mix of fresh `index = 0` and replayed `index = 1` sprites. `Scene::batches` emits separate batches per `texture_id`; the `index = 1` batch reaches `atlas.get_texture_view` → `subpixel_textures[1]` → panic with `len = 1, index = 1`. The two earlier related fixes do not catch this: - **#52389 / dbd95ea7** (`if force_render { mark_drawable }`) protects the 200 ms recovery sleep — pending `WM_PAINT`s carry `force_render = false` and so do not clear `skip_draws`. But `WM_GPUI_FORCE_UPDATE_WINDOW` carries `force_render = true`, so `mark_drawable` runs, then `Window::draw`'s `reuse_paint` still reproduces stale tiles. - The unmerged Windows draft `2e5d890e37` (`force_render_after_recovery`) similarly only forces the forced-render branch — it doesn't bypass the view cache. ## Fix Two parts: **1. Bypass the view cache on a forced draw (cross-platform).** In the platform-agnostic `request_frame` closure in `Window::new`, call `window.refresh()` whenever `RequestFrameOptions::force_render` is `true`. `Window::refresh` is the documented escape hatch for cached views (per the `AnyView::cached` docs: *"The one exception is when [Window::refresh] is called, in which case caching is ignored."*). With `refreshing = true` every `AnyView::prepaint` cache check fails, every cached view fully repaints, and `paint_glyph` allocates fresh tiles for every glyph, so `rendered_frame.scene` ends up free of stale `AtlasTile`s. **2. Add the `force_render_after_recovery` flag on Windows.** Mirror the Linux fix from #52389: a per-window `Cell` set after `WindowsWindowInner::handle_device_lost` succeeds and consumed at the top of `draw_window`. Together with the GPUI change above, the first frame after recovery (whether a stray `WM_PAINT` during the 200 ms recovery sleep or the explicit `WM_GPUI_FORCE_UPDATE_WINDOW`) is treated as a forced render that both clears `skip_draws` and bypasses the view cache. ## Testing - `script/clippy -p gpui` is clean. - I do not have a Windows toolchain available locally, so I have not cross-compiled `gpui_windows`. Reviewers with Windows access — please smoke-test on a machine where the device-lost path can be exercised (Intel iGPU, suspend/resume, or running a TDR-inducing test on a GPU driver). ## Related - Sentry issue ID 7457971403 (DirectX subpixel atlas crash, Intel Iris Xe). - Builds on / fixes the residual gap in #52389 (`gpui_linux: Force scene rebuild after GPU device recovery"). The GPUI change here also hardens the corresponding Linux path against the same `reuse_paint` mechanism. Release Notes: - Fixed a crash on Windows when the GPU device is lost and recovered during use (typically driver crash, suspend/resume, or display reconfiguration, most commonly on Intel iGPUs) --- crates/gpui/src/window.rs | 5 +++++ crates/gpui_windows/src/events.rs | 6 ++++++ crates/gpui_windows/src/window.rs | 7 +++++++ 3 files changed, 18 insertions(+) diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index dc387c67f39817..46b1ab64a188ca 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1402,6 +1402,11 @@ impl Window { measure("frame duration", || { handle .update(&mut cx, |_, window, cx| { + if request_frame_options.force_render { + // Bypass cached view reuse so we don't replay stale + // atlas tile references after a GPU device recovery. + window.refresh(); + } let arena_clear_needed = window.draw(cx); window.present(); arena_clear_needed.clear(); diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index a4c47789191f9c..77c4cde9788f7c 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -1174,6 +1174,11 @@ impl WindowsWindowInner { { panic!("Device lost: {err}"); } + // Make sure the first `draw_window` after recovery (whether it comes + // from the forced WM_GPUI_FORCE_UPDATE_WINDOW or a stray WM_PAINT in + // between) is treated as a forced render so it both clears + // `skip_draws` and bypasses the view cache. + self.state.force_render_after_recovery.set(true); Some(0) } @@ -1198,6 +1203,7 @@ impl WindowsWindowInner { } } + let force_render = force_render || self.state.force_render_after_recovery.take(); if force_render { // Re-enable drawing after a device loss recovery. The forced render // will rebuild the scene with fresh atlas textures. diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 130d3dd7214b2c..178d750024fdac 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -63,6 +63,12 @@ pub struct WindowsWindowState { pub direct_manipulation: DirectManipulationHandler, pub renderer: RefCell, + /// Set after a GPU device-lost recovery so the next `draw_window` call is + /// treated as a forced render. This guarantees the next frame both + /// re-enables drawing (via `mark_drawable`) and bypasses the GPUI view + /// cache, which would otherwise replay stale atlas tile references from + /// the previous frame and panic in `DirectXAtlasState::texture`. + pub force_render_after_recovery: Cell, pub click_state: ClickState, pub current_cursor: Cell>, @@ -159,6 +165,7 @@ impl WindowsWindowState { last_reported_capslock: Cell::new(last_reported_capslock), hovered: Cell::new(hovered), renderer: RefCell::new(renderer), + force_render_after_recovery: Cell::new(false), click_state, current_cursor: Cell::new(current_cursor), cursor_visible, From 47ea7de9c8e154fa5ba27c36d0907ea0a60c51e9 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 7 May 2026 12:51:34 +0200 Subject: [PATCH 09/33] Fix leak detector causing panics in unit evals (#56029) Fixed an issue where the leak detector would sometimes cause panics when running unit evals. Fixed this by matching the tear-down logic that we use in the `gpui::test` macro > thread 'tools::evals::edit_file::eval_from_pixels_constructor' (14336149) panicked at crates/gpui/src/app/entity_map.rs:1116:9: Exited with leaked handles: Leaked handle for entity language::buffer::Buffer (EntityId(50v1)): Release Notes: - N/A --- crates/agent/src/tools/evals.rs | 46 +++++++++++++++++++ crates/agent/src/tools/evals/edit_file.rs | 38 ++++++--------- crates/agent/src/tools/evals/terminal_tool.rs | 40 +++++++--------- crates/agent/src/tools/evals/write_file.rs | 36 ++++++--------- 4 files changed, 90 insertions(+), 70 deletions(-) diff --git a/crates/agent/src/tools/evals.rs b/crates/agent/src/tools/evals.rs index 3096068931161d..ac11ffe74a03a4 100644 --- a/crates/agent/src/tools/evals.rs +++ b/crates/agent/src/tools/evals.rs @@ -1,6 +1,52 @@ +#[cfg(all(test, feature = "unit-eval"))] +use futures::future::LocalBoxFuture; +#[cfg(all(test, feature = "unit-eval"))] +use gpui::TestAppContext; +#[cfg(all(test, feature = "unit-eval"))] +use std::fmt::Display; + #[cfg(all(test, feature = "unit-eval"))] mod edit_file; #[cfg(all(test, feature = "unit-eval"))] mod terminal_tool; #[cfg(all(test, feature = "unit-eval"))] mod write_file; + +#[cfg(all(test, feature = "unit-eval"))] +fn run_gpui_eval( + eval: impl for<'a> FnOnce(&'a mut TestAppContext) -> LocalBoxFuture<'a, anyhow::Result>, + outcome: impl FnOnce(&T) -> eval_utils::OutcomeKind, +) -> eval_utils::EvalOutput<()> +where + T: Display, +{ + let dispatcher = gpui::TestDispatcher::new(rand::random()); + let mut cx = TestAppContext::build(dispatcher.clone(), None); + let entity_refcounts = cx.app.borrow().ref_counts_drop_handle(); + let foreground_executor = cx.foreground_executor().clone(); + let result = foreground_executor.block_test(eval(&mut cx)); + + cx.run_until_parked(); + cx.update(|cx| { + cx.background_executor().forbid_parking(); + cx.quit(); + }); + cx.run_until_parked(); + drop(cx); + dispatcher.drain_tasks(); + drop(dispatcher); + drop(entity_refcounts); + + match result { + Ok(output) => eval_utils::EvalOutput { + data: output.to_string(), + outcome: outcome(&output), + metadata: (), + }, + Err(err) => eval_utils::EvalOutput { + data: format!("{err:?}"), + outcome: eval_utils::OutcomeKind::Error, + metadata: (), + }, + } +} diff --git a/crates/agent/src/tools/evals/edit_file.rs b/crates/agent/src/tools/evals/edit_file.rs index 4c96b0797f8770..79c5a7c2689524 100644 --- a/crates/agent/src/tools/evals/edit_file.rs +++ b/crates/agent/src/tools/evals/edit_file.rs @@ -547,33 +547,25 @@ impl EditToolTest { } fn run_eval(eval: EvalInput) -> eval_utils::EvalOutput<()> { - let dispatcher = gpui::TestDispatcher::new(rand::random()); - let mut cx = TestAppContext::build(dispatcher, None); - let foreground_executor = cx.foreground_executor().clone(); - let result = foreground_executor.block_test(async { - let test = EditToolTest::new(&mut cx).await; - let result = test.eval(eval, &mut cx).await; - drop(test); - cx.run_until_parked(); - result - }); - cx.quit(); - match result { - Ok(output) => eval_utils::EvalOutput { - data: output.to_string(), - outcome: if output.assertion.score < 80 { + super::run_gpui_eval( + |cx| { + async move { + let test = EditToolTest::new(cx).await; + let result = test.eval(eval, cx).await; + drop(test); + cx.run_until_parked(); + result + } + .boxed_local() + }, + |output| { + if output.assertion.score < 80 { eval_utils::OutcomeKind::Failed } else { eval_utils::OutcomeKind::Passed - }, - metadata: (), - }, - Err(err) => eval_utils::EvalOutput { - data: format!("{err:?}"), - outcome: eval_utils::OutcomeKind::Error, - metadata: (), + } }, - } + ) } fn message( diff --git a/crates/agent/src/tools/evals/terminal_tool.rs b/crates/agent/src/tools/evals/terminal_tool.rs index 3769df5abed0bc..92ebd61d1622d5 100644 --- a/crates/agent/src/tools/evals/terminal_tool.rs +++ b/crates/agent/src/tools/evals/terminal_tool.rs @@ -2,7 +2,7 @@ use crate::{AgentTool, Template, Templates, TerminalTool, TerminalToolInput}; use Role::*; use anyhow::{Context as _, Result}; use client::{Client, RefreshLlmTokenListener, UserStore}; -use futures::StreamExt; +use futures::{FutureExt as _, StreamExt}; use gpui::{AppContext as _, AsyncApp, TestAppContext}; use http_client::StatusCode; use language_model::{ @@ -428,33 +428,25 @@ async fn retry_on_rate_limit(mut request: impl AsyncFnMut() -> Result) -> } fn run_eval(eval: EvalInput) -> eval_utils::EvalOutput<()> { - let dispatcher = gpui::TestDispatcher::new(rand::random()); - let mut cx = TestAppContext::build(dispatcher, None); - let foreground_executor = cx.foreground_executor().clone(); - let result = foreground_executor.block_test(async { - let test = TerminalToolTest::new(&mut cx).await; - let result = test.eval(eval, &mut cx).await; - drop(test); - cx.run_until_parked(); - result - }); - cx.quit(); - match result { - Ok(output) => eval_utils::EvalOutput { - data: output.to_string(), - outcome: if output.assertion.score < 80 { + super::run_gpui_eval( + |cx| { + async move { + let test = TerminalToolTest::new(cx).await; + let result = test.eval(eval, cx).await; + drop(test); + cx.run_until_parked(); + result + } + .boxed_local() + }, + |output| { + if output.assertion.score < 80 { eval_utils::OutcomeKind::Failed } else { eval_utils::OutcomeKind::Passed - }, - metadata: (), - }, - Err(err) => eval_utils::EvalOutput { - data: format!("{err:?}"), - outcome: eval_utils::OutcomeKind::Error, - metadata: (), + } }, - } + ) } fn message( diff --git a/crates/agent/src/tools/evals/write_file.rs b/crates/agent/src/tools/evals/write_file.rs index f34528fcd78577..60eda3ab3e6513 100644 --- a/crates/agent/src/tools/evals/write_file.rs +++ b/crates/agent/src/tools/evals/write_file.rs @@ -6,7 +6,7 @@ use Role::*; use anyhow::{Context as _, Result}; use client::{Client, RefreshLlmTokenListener, UserStore}; use fs::FakeFs; -use futures::StreamExt; +use futures::{FutureExt as _, StreamExt}; use gpui::{AppContext as _, AsyncApp, Entity, TestAppContext, UpdateGlobal as _}; use http_client::StatusCode; use language::language_settings::FormatOnSave; @@ -365,29 +365,19 @@ impl WriteToolTest { } fn run_eval(eval: EvalInput) -> eval_utils::EvalOutput<()> { - let dispatcher = gpui::TestDispatcher::new(rand::random()); - let mut cx = TestAppContext::build(dispatcher, None); - let foreground_executor = cx.foreground_executor().clone(); - let result = foreground_executor.block_test(async { - let test = WriteToolTest::new(&mut cx).await; - let result = test.eval(eval, &mut cx).await; - drop(test); - cx.run_until_parked(); - result - }); - cx.quit(); - match result { - Ok(output) => eval_utils::EvalOutput { - data: output.to_string(), - outcome: eval_utils::OutcomeKind::Passed, - metadata: (), - }, - Err(err) => eval_utils::EvalOutput { - data: format!("{err:?}"), - outcome: eval_utils::OutcomeKind::Error, - metadata: (), + super::run_gpui_eval( + |cx| { + async move { + let test = WriteToolTest::new(cx).await; + let result = test.eval(eval, cx).await; + drop(test); + cx.run_until_parked(); + result + } + .boxed_local() }, - } + |_| eval_utils::OutcomeKind::Passed, + ) } fn message( From 59daeba295f2fa04de0f81b87aa9772c6b8ea86c Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Thu, 7 May 2026 05:55:07 -0500 Subject: [PATCH 10/33] vim: Add setting to control whether edit predictions are shown in normal mode (#55956) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - Added a setting [vim.show_edit_predictions_in_normal_mode](zed://settings/vim.show_edit_predictions_in_normal_mode) to control whether edit predictions are shown in normal mode. --- assets/settings/default.json | 3 +++ .../settings_content/src/settings_content.rs | 3 +++ crates/settings_ui/src/page_data.rs | 24 ++++++++++++++++++- crates/vim/src/state.rs | 4 ++++ crates/vim/src/vim.rs | 6 ++++- 5 files changed, 38 insertions(+), 2 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 64f97c451b00ea..d6d6feac644161 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -2515,6 +2515,9 @@ "gdefault": false, "highlight_on_yank_duration": 200, "custom_digraphs": {}, + // When enabled, edit predictions are shown in Vim normal mode. + // By default, edit predictions are only shown in insert and replace modes. + "show_edit_predictions_in_normal_mode": false, // Cursor shape for each mode. // The shape can be one of the following: "block", "bar", "underline", "hollow". "cursor_shape": { diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 1124e2ac942605..0bf14e2f4fffcf 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -864,6 +864,9 @@ pub struct VimSettingsContent { pub custom_digraphs: Option>>, pub highlight_on_yank_duration: Option, pub cursor_shape: Option, + /// When enabled, edit predictions are shown in Vim normal mode. + /// By default, edit predictions are only shown in insert and replace modes. + pub show_edit_predictions_in_normal_mode: Option, } #[derive( diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index ce0c53b3822e26..acb9f53a675297 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -2573,7 +2573,7 @@ fn editor_page() -> SettingsPage { ] } - fn vim_settings_section() -> [SettingsPageItem; 13] { + fn vim_settings_section() -> [SettingsPageItem; 14] { [ SettingsPageItem::SectionHeader("Vim"), SettingsPageItem::SettingItem(SettingItem { @@ -2700,6 +2700,28 @@ fn editor_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Show Edit Predictions in Normal Mode", + description: "Whether edit predictions are shown in normal mode. By default, edit predictions are only shown in insert and replace modes.", + field: Box::new(SettingField { + json_path: Some("vim.show_edit_predictions_in_normal_mode"), + pick: |settings_content| { + settings_content + .vim + .as_ref()? + .show_edit_predictions_in_normal_mode + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .vim + .get_or_insert_default() + .show_edit_predictions_in_normal_mode = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Cursor Shape - Normal Mode", description: "Cursor shape for normal mode.", diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 0851604e1abcdf..85bf84d887830b 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -79,6 +79,10 @@ impl Mode { pub fn is_helix(&self) -> bool { matches!(self, Self::HelixNormal | Self::HelixSelect) } + + pub fn is_normal(&self) -> bool { + matches!(self, Self::Normal | Self::HelixNormal) + } } #[derive(Clone, Debug, PartialEq)] diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 6c0c3d0201b490..d247e24031075c 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -2209,7 +2209,9 @@ impl Vim { autoindent: self.should_autoindent(), cursor_offset_on_selection: self.mode.is_visual() || self.mode.is_helix(), line_mode: matches!(self.mode, Mode::VisualLine), - hide_edit_predictions: !matches!(self.mode, Mode::Insert | Mode::Replace), + hide_edit_predictions: !matches!(self.mode, Mode::Insert | Mode::Replace) + && !(self.mode.is_normal() + && VimSettings::get_global(cx).show_edit_predictions_in_normal_mode), } } @@ -2259,6 +2261,7 @@ struct VimSettings { pub custom_digraphs: HashMap>, pub highlight_on_yank_duration: u64, pub cursor_shape: CursorShapeSettings, + pub show_edit_predictions_in_normal_mode: bool, } /// Cursor shape configuration for insert mode. @@ -2346,6 +2349,7 @@ impl Settings for VimSettings { custom_digraphs: vim.custom_digraphs.unwrap(), highlight_on_yank_duration: vim.highlight_on_yank_duration.unwrap(), cursor_shape: vim.cursor_shape.unwrap().into(), + show_edit_predictions_in_normal_mode: vim.show_edit_predictions_in_normal_mode.unwrap(), } } } From e6b8b30e2218dbe806a2787a668186e5b7130490 Mon Sep 17 00:00:00 2001 From: David Alecrim <35930364+davidalecrim1@users.noreply.github.com> Date: Thu, 7 May 2026 09:26:13 -0300 Subject: [PATCH 11/33] markdown: Improve table cell alignment (#53465) ## Summary Markdown preview tables kept text pinned to the top of a row when a neighboring cell contained a taller image. This made mixed text-and-image tables look unbalanced and inconsistent with common editor behavior. This change makes table text stay visually centered within taller rows so Markdown tables are easier to scan and match expected rendering more closely. ## Before / After | Before | After | | --- | --- | | Screenshot 2026-04-08 at 19 55 50 | Screenshot 2026-04-08 at 21 47
31 | ## References Inspired by comparing this with VS Code preview Screenshot 2026-04-08 at 21 54 06 Release Notes: - Improved Markdown preview table cells to vertically center content in tall rows and respect column alignment from the table header. --------- Co-authored-by: Smit Barmase --- crates/markdown/src/html/html_rendering.rs | 31 +++++++++- crates/markdown/src/markdown.rs | 68 +++++++++++++++++----- 2 files changed, 85 insertions(+), 14 deletions(-) diff --git a/crates/markdown/src/html/html_rendering.rs b/crates/markdown/src/html/html_rendering.rs index 27e9b70e8e80ab..af46dfe2b2c039 100644 --- a/crates/markdown/src/html/html_rendering.rs +++ b/crates/markdown/src/html/html_rendering.rs @@ -1,6 +1,8 @@ use std::ops::Range; -use gpui::{App, FontStyle, FontWeight, StrikethroughStyle, TextStyleRefinement, UnderlineStyle}; +use gpui::{ + App, FontStyle, FontWeight, StrikethroughStyle, TextAlign, TextStyleRefinement, UnderlineStyle, +}; use pulldown_cmark::Alignment; use ui::prelude::*; @@ -245,14 +247,24 @@ impl MarkdownElement { } let max_span = max_column_count.saturating_sub(column_index); + let text_align = match cell.alignment { + Alignment::Left => TextAlign::Left, + Alignment::Center => TextAlign::Center, + Alignment::Right => TextAlign::Right, + _ => self.style.base_text_style.text_align, + }; + 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) + .flex() + .flex_col() .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() + .h_full() .when(cell.is_header, |this| { this.bg(cx.theme().colors().title_bar_background) }) @@ -266,7 +278,22 @@ impl MarkdownElement { _ => cell_div, }; + builder.push_text_style(TextStyleRefinement { + text_align: Some(text_align), + ..Default::default() + }); builder.push_div(cell_div, &table.source_range, markdown_end); + builder.push_div( + div() + .flex() + .flex_col() + .flex_1() + .w_full() + .justify_center() + .text_align(text_align), + &table.source_range, + markdown_end, + ); self.render_html_paragraph( &cell.children, source_allocator, @@ -275,6 +302,8 @@ impl MarkdownElement { markdown_end, ); builder.pop_div(); + builder.pop_div(); + builder.pop_text_style(); for row_offset in 0..cell.row_span { for column_offset in 0..cell.col_span { diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index dce9633c87b050..937e38c39509cc 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -2000,20 +2000,49 @@ impl Element for MarkdownElement { let is_header = builder.table.in_head; let row_index = builder.table.row_index; let col_index = builder.table.col_index; + let alignment = builder.table.alignments.get(col_index).copied(); + let text_align = match alignment { + Some(Alignment::Left) => TextAlign::Left, + Some(Alignment::Center) => TextAlign::Center, + Some(Alignment::Right) => TextAlign::Right, + _ => self.style.base_text_style.text_align, + }; + + let mut cell_div = div() + .flex() + .flex_col() + .h_full() + .when(col_index > 0, |this| this.border_l_1()) + .when(row_index > 0, |this| this.border_t_1()) + .border_color(cx.theme().colors().border) + .px_1() + .py_0p5() + .when(is_header, |this| { + this.bg(cx.theme().colors().title_bar_background) + }) + .when(!is_header && row_index % 2 == 1, |this| { + this.bg(cx.theme().colors().panel_background) + }); + + cell_div = match alignment { + Some(Alignment::Center) => cell_div.items_center(), + Some(Alignment::Right) => cell_div.items_end(), + _ => cell_div, + }; + builder.push_text_style(TextStyleRefinement { + text_align: Some(text_align), + ..Default::default() + }); + builder.push_div(cell_div, range, markdown_end); builder.push_div( div() - .when(col_index > 0, |this| this.border_l_1()) - .when(row_index > 0, |this| this.border_t_1()) - .border_color(cx.theme().colors().border) - .px_1() - .py_0p5() - .when(is_header, |this| { - this.bg(cx.theme().colors().title_bar_background) - }) - .when(!is_header && row_index % 2 == 1, |this| { - this.bg(cx.theme().colors().panel_background) - }), + .flex() + .flex_col() + .flex_1() + .w_full() + .justify_center() + .text_align(text_align), range, markdown_end, ); @@ -2113,6 +2142,8 @@ impl Element for MarkdownElement { MarkdownTagEnd::TableCell => { builder.replace_pending_checkbox(self.on_checkbox_toggle.clone()); builder.pop_div(); + builder.pop_div(); + builder.pop_text_style(); builder.table.end_cell(); } MarkdownTagEnd::FootnoteDefinition => { @@ -2702,7 +2733,7 @@ impl MarkdownElementBuilder { ) .fill(); - let element = if let Some(on_toggle) = on_toggle { + let checkbox = if let Some(on_toggle) = on_toggle { checkbox .on_click(move |_state, window, cx| { on_toggle(marker_source.clone(), !checked, window, cx); @@ -2711,7 +2742,18 @@ impl MarkdownElementBuilder { } else { checkbox.visualization_only(true).into_any_element() }; - self.div_stack.last_mut().unwrap().extend([element]); + + let mut checkbox_container = h_flex().w_full(); + checkbox_container = match self.text_style().text_align { + TextAlign::Left => checkbox_container.justify_start(), + TextAlign::Center => checkbox_container.justify_center(), + TextAlign::Right => checkbox_container.justify_end(), + }; + + self.div_stack + .last_mut() + .unwrap() + .extend([checkbox_container.child(checkbox).into_any_element()]); } fn source_range_for_rendered(&self, rendered: &Range) -> Option> { From 9a125a553dd2e031c1a7fd76091147e70aa89211 Mon Sep 17 00:00:00 2001 From: Neel Date: Thu, 7 May 2026 13:40:48 +0100 Subject: [PATCH 12/33] agent_ui: Preserve selection mentions when starting a new thread (#55203) When the "+" button created a fresh draft, `active_initial_content` fell back to the raw editor text when the async `draft_prompt` observer had not yet resolved. That raw text contains fold placeholder strings (e.g. "selection") rather than the mention links, so creases and their registered URIs were dropped from the carried-over draft. Related to https://github.com/zed-industries/zed/issues/53981. Release Notes: - Fixed a bug where selection mentions would resolve to the literal `selection` rather than the URI in draft threads. --- crates/agent_ui/src/agent_panel.rs | 41 ++--- crates/agent_ui/src/mention_set.rs | 10 + crates/agent_ui/src/message_editor.rs | 253 ++++++++++++++++++-------- crates/editor/src/display_map.rs | 4 + 4 files changed, 205 insertions(+), 103 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 921d1347ffb6c3..7a2ee6d00c09dd 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -2597,31 +2597,26 @@ impl AgentPanel { } fn active_initial_content(&self, cx: &App) -> Option { - self.active_thread_view(cx).and_then(|thread_view| { + let thread_view = self.active_thread_view(cx)?; + let thread_view = thread_view.read(cx); + let saved = thread_view + .thread + .read(cx) + .draft_prompt() + .map(|blocks| blocks.to_vec()) + .filter(|blocks| !blocks.is_empty()); + let blocks = saved.unwrap_or_else(|| { thread_view + .message_editor .read(cx) - .thread - .read(cx) - .draft_prompt() - .map(|draft| AgentInitialContent::ContentBlock { - blocks: draft.to_vec(), - auto_submit: false, - }) - .filter(|initial_content| match initial_content { - AgentInitialContent::ContentBlock { blocks, .. } => !blocks.is_empty(), - _ => true, - }) - .or_else(|| { - let text = thread_view.read(cx).message_editor.read(cx).text(cx); - if text.trim().is_empty() { - None - } else { - Some(AgentInitialContent::ContentBlock { - blocks: vec![acp::ContentBlock::Text(acp::TextContent::new(text))], - auto_submit: false, - }) - } - }) + .draft_content_blocks_snapshot(cx) + }); + if blocks.is_empty() { + return None; + } + Some(AgentInitialContent::ContentBlock { + blocks, + auto_submit: false, }) } diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 8c98b9458bbce8..fc2cc6523c8d3e 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -178,6 +178,16 @@ impl MentionSet { self.mentions.get(crease_id).map(|(uri, _)| uri.clone()) } + /// Returns the resolved mention for a crease, if any. + pub fn resolved_mention_for_crease( + &self, + crease_id: &CreaseId, + ) -> Option<(MentionUri, Option)> { + let (uri, task) = self.mentions.get(crease_id)?; + let mention = task.clone().now_or_never().and_then(|result| result.ok()); + Some((uri.clone(), mention)) + } + pub fn set_mentions(&mut self, mentions: HashMap) { self.mentions = mentions; } diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 66887019d31294..c6fd040f7e91e9 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -17,6 +17,7 @@ use editor::{ EditorStyle, Inlay, MultiBuffer, MultiBufferOffset, MultiBufferSnapshot, ToOffset, actions::{Copy, Paste}, code_context_menus::CodeContextMenu, + display_map::{CreaseId, CreaseSnapshot}, scroll::Autoscroll, }; use futures::{FutureExt as _, future::join_all}; @@ -768,90 +769,46 @@ impl MessageEditor { self.session_capabilities.read().supports_embedded_context(); cx.spawn(async move |_, cx| { - let contents = contents.await?; - let mut all_tracked_buffers = Vec::new(); - - let result = editor.update(cx, |editor, cx| { + let mut contents = contents.await?; + Ok(editor.update(cx, |editor, cx| { + let crease_snapshot = editor.display_map.read(cx).crease_snapshot(); + let buffer_snapshot = editor.buffer().read(cx).snapshot(cx); let text = editor.text(cx); - let (mut ix, _) = text - .char_indices() - .find(|(_, c)| !c.is_whitespace()) - .unwrap_or((0, '\0')); - let mut chunks: Vec = Vec::new(); - editor.display_map.update(cx, |map, cx| { - let snapshot = map.snapshot(cx); - for (crease_id, crease) in snapshot.crease_snapshot.creases() { - let Some((uri, mention)) = contents.get(&crease_id) else { - continue; - }; - - let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot()); - if crease_range.start.0 > ix { - let chunk = text[ix..crease_range.start.0].into(); - chunks.push(chunk); - } - let chunk = match mention { - Mention::Text { - content, - tracked_buffers, - } => { - all_tracked_buffers.extend(tracked_buffers.iter().cloned()); - if supports_embedded_context { - acp::ContentBlock::Resource(acp::EmbeddedResource::new( - acp::EmbeddedResourceResource::TextResourceContents( - acp::TextResourceContents::new( - content.clone(), - uri.to_uri().to_string(), - ), - ), - )) - } else { - acp::ContentBlock::ResourceLink(acp::ResourceLink::new( - uri.name(), - uri.to_uri().to_string(), - )) - } - } - Mention::Image(mention_image) => acp::ContentBlock::Image( - acp::ImageContent::new( - mention_image.data.clone(), - mention_image.format.mime_type(), - ) - .uri(match uri { - MentionUri::File { .. } => Some(uri.to_uri().to_string()), - MentionUri::PastedImage { .. } => { - Some(uri.to_uri().to_string()) - } - other => { - debug_panic!( - "unexpected mention uri for image: {:?}", - other - ); - None - } - }), - ), - Mention::Link => acp::ContentBlock::ResourceLink( - acp::ResourceLink::new(uri.name(), uri.to_uri().to_string()), - ), - }; - chunks.push(chunk); - ix = crease_range.end.0; - } - - if ix < text.len() { - let last_chunk = text[ix..].trim_end().to_owned(); - if !last_chunk.is_empty() { - chunks.push(last_chunk.into()); - } - } - }); - anyhow::Ok((chunks, all_tracked_buffers)) - })?; - Ok(result) + build_chunks_from_creases( + &text, + &crease_snapshot, + &buffer_snapshot, + supports_embedded_context, + |crease_id| { + contents + .remove(crease_id) + .map(|(uri, mention)| (uri, Some(mention))) + }, + ) + })) }) } + /// Snapshots the editor's current draft into a list of `ContentBlock`s + /// without awaiting any pending mention resolution. + pub fn draft_content_blocks_snapshot(&self, cx: &App) -> Vec { + let editor = self.editor.read(cx); + let crease_snapshot = editor.display_map.read(cx).crease_snapshot(); + let buffer_snapshot = editor.buffer().read(cx).snapshot(cx); + let text = editor.text(cx); + let mention_set = self.mention_set.read(cx); + let supports_embedded_context = + self.session_capabilities.read().supports_embedded_context(); + let (chunks, _tracked_buffers) = build_chunks_from_creases( + &text, + &crease_snapshot, + &buffer_snapshot, + supports_embedded_context, + |crease_id| mention_set.resolved_mention_for_crease(crease_id), + ); + chunks + } + pub fn clear(&mut self, window: &mut Window, cx: &mut Context) { self.editor.update(cx, |editor, cx| { editor.clear(window, cx); @@ -1874,6 +1831,92 @@ impl Addon for MessageEditorAddon { } } +/// Walks the editor's creases in order, interleaving plain-text chunks from +/// `text` with mention blocks produced from `resolve`. +fn build_chunks_from_creases( + text: &str, + crease_snapshot: &CreaseSnapshot, + buffer_snapshot: &MultiBufferSnapshot, + supports_embedded_context: bool, + mut resolve: impl FnMut(&CreaseId) -> Option<(MentionUri, Option)>, +) -> (Vec, Vec>) { + let mut ix = text + .char_indices() + .find(|(_, c)| !c.is_whitespace()) + .map_or(text.len(), |(i, _)| i); + let mut chunks = Vec::new(); + let mut tracked_buffers = Vec::new(); + + for (crease_id, crease) in crease_snapshot.creases() { + let Some((uri, mention)) = resolve(&crease_id) else { + continue; + }; + let crease_range = crease.range().to_offset(buffer_snapshot); + if crease_range.start.0 > ix { + chunks.push(text[ix..crease_range.start.0].into()); + } + chunks.push(mention_to_content_block( + &uri, + mention.as_ref(), + supports_embedded_context, + &mut tracked_buffers, + )); + ix = crease_range.end.0; + } + + if ix < text.len() { + let last_chunk = text[ix..].trim_end().to_owned(); + if !last_chunk.is_empty() { + chunks.push(last_chunk.into()); + } + } + (chunks, tracked_buffers) +} + +fn mention_to_content_block( + uri: &MentionUri, + mention: Option<&Mention>, + supports_embedded_context: bool, + tracked_buffers: &mut Vec>, +) -> acp::ContentBlock { + match mention { + Some(Mention::Text { + content, + tracked_buffers: mention_tracked_buffers, + }) => { + tracked_buffers.extend(mention_tracked_buffers.iter().cloned()); + if supports_embedded_context { + acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new(content.clone(), uri.to_uri().to_string()), + ), + )) + } else { + acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + uri.name(), + uri.to_uri().to_string(), + )) + } + } + Some(Mention::Image(mention_image)) => acp::ContentBlock::Image( + acp::ImageContent::new(mention_image.data.clone(), mention_image.format.mime_type()) + .uri(match uri { + MentionUri::File { .. } | MentionUri::PastedImage { .. } => { + Some(uri.to_uri().to_string()) + } + other => { + debug_panic!("unexpected mention uri for image: {:?}", other); + None + } + }), + ), + _ => acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + uri.name(), + uri.to_uri().to_string(), + )), + } +} + /// Parses markdown mention links in the format `[@name](uri)` from text. /// Returns a vector of (range, MentionUri) pairs where range is the byte range in the text. fn parse_mention_links(text: &str, path_style: PathStyle) -> Vec<(Range, MentionUri)> { @@ -4197,6 +4240,56 @@ mod tests { assert_eq!(copied, None); } + #[gpui::test] + async fn test_draft_content_blocks_snapshot_preserves_selection_mentions( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let (fixture, mut cx) = setup_selection_mention_fixture(cx).await; + + let blocks = fixture.message_editor.update(&mut cx, |editor, cx| { + editor + .session_capabilities + .write() + .set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true)); + editor.draft_content_blocks_snapshot(cx) + }); + + // Each selection mention must round-trip as a `Resource` block carrying + // its URI and content, not as a `Text` block containing the fold + // placeholder string. + let resource_uris: Vec<&str> = + blocks + .iter() + .filter_map(|block| match block { + acp::ContentBlock::Resource(acp::EmbeddedResource { + resource: + acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents { uri, .. }, + ), + .. + }) => Some(uri.as_str()), + _ => None, + }) + .collect(); + assert_eq!( + resource_uris.len(), + 2, + "snapshot should emit one Resource block per selection mention; got {blocks:#?}" + ); + assert!(resource_uris.contains(&fixture.first_uri.to_uri().to_string().as_str())); + for block in &blocks { + if let acp::ContentBlock::Text(text) = block { + assert!( + !text.text.split_whitespace().any(|word| word == "selection"), + "text block must not contain bare fold placeholder: {:?}", + text.text + ); + } + } + } + #[gpui::test] async fn test_paste_mention_link_with_completion_trigger_does_not_panic( cx: &mut TestAppContext, diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index db01bbb178694f..f7433c96448d29 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -689,6 +689,10 @@ impl DisplayMap { } } + pub fn crease_snapshot(&self) -> CreaseSnapshot { + self.crease_map.snapshot() + } + #[instrument(skip_all)] pub fn set_state(&mut self, other: &DisplaySnapshot, cx: &mut Context) { self.fold( From 4f54a04147f139db9ca1f51ff30350f59554faab Mon Sep 17 00:00:00 2001 From: Neel Date: Thu, 7 May 2026 13:40:53 +0100 Subject: [PATCH 13/33] agent_ui: Restore `Ctrl + >` behavior for whole lines (#54698) Restores current line fallback when using `Ctrl + >` to add context to the agent. Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 65 +-- crates/agent_ui/src/completion_provider.rs | 609 ++++++++++++++------- crates/agent_ui/src/conversation_view.rs | 29 +- crates/agent_ui/src/message_editor.rs | 33 +- crates/terminal_view/src/terminal_panel.rs | 6 +- crates/workspace/src/dock.rs | 12 +- 6 files changed, 474 insertions(+), 280 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 7a2ee6d00c09dd..e60a4834ae2b06 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -32,6 +32,7 @@ use zed_actions::{ use crate::ExpandMessageEditor; use crate::ManageProfiles; use crate::agent_connection_store::AgentConnectionStore; +use crate::completion_provider::AgentContextSource; use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore, ThreadMetadataStoreEvent}; use crate::{ AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, Follow, @@ -67,10 +68,7 @@ use language_model::LanguageModelRegistry; use project::{Project, ProjectPath, Worktree}; use prompt_store::{PromptStore, UserPromptId}; use rules_library::{RulesLibrary, open_rules_library}; -use settings::TerminalDockPosition; use settings::{Settings, update_settings_file}; -use terminal::terminal_settings::TerminalSettings; -use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; use theme_settings::ThemeSettings; use ui::{ Button, ContextMenu, ContextMenuEntry, IconButton, PopoverMenu, PopoverMenuHandle, Tab, @@ -413,61 +411,36 @@ pub fn init(cx: &mut App) { ) .register_action( |workspace: &mut Workspace, _: &AddSelectionToThread, window, cx| { - let active_editor = workspace - .active_item(cx) - .and_then(|item| item.act_as::(cx)); - let has_editor_selection = active_editor.is_some_and(|editor| { - editor.update(cx, |editor, cx| { - editor.has_non_empty_selection(&editor.display_snapshot(cx)) - }) - }); - - let has_terminal_selection = workspace - .active_item(cx) - .and_then(|item| item.act_as::(cx)) - .is_some_and(|terminal_view| { - terminal_view - .read(cx) - .terminal() - .read(cx) - .last_content - .selection_text - .as_ref() - .is_some_and(|text| !text.is_empty()) - }); + let Some(agent_panel) = workspace.panel::(cx) else { + return; + }; - let has_terminal_panel_selection = - workspace.panel::(cx).is_some_and(|panel| { - let position = match TerminalSettings::get_global(cx).dock { - TerminalDockPosition::Left => DockPosition::Left, - TerminalDockPosition::Bottom => DockPosition::Bottom, - TerminalDockPosition::Right => DockPosition::Right, - }; - let dock_is_open = - workspace.dock_at_position(position).read(cx).is_open(); - dock_is_open && !panel.read(cx).terminal_selections(cx).is_empty() - }); + let source = AgentContextSource::from_focused(workspace, window, cx); + let source = source.or_else(|| { + let cached = agent_panel.read(cx).last_context_source.clone()?; + cached.exists(workspace, cx).then_some(cached) + }); + let source = + source.or_else(|| AgentContextSource::from_active(workspace, cx)); - if !has_editor_selection - && !has_terminal_selection - && !has_terminal_panel_selection - { + let Some(source) = source else { return; - } + }; - let Some(panel) = workspace.panel::(cx) else { + let Some(selection) = source.read_selection(workspace, true, cx) else { return; }; - if !panel.focus_handle(cx).contains_focused(window, cx) { + if !agent_panel.focus_handle(cx).contains_focused(window, cx) { workspace.toggle_panel_focus::(window, cx); } - panel.update(cx, |_, cx| { + agent_panel.update(cx, |panel, cx| { + panel.last_context_source = Some(source); cx.defer_in(window, move |panel, window, cx| { if let Some(conversation_view) = panel.active_conversation_view() { conversation_view.update(cx, |conversation_view, cx| { - conversation_view.insert_selections(window, cx); + conversation_view.insert_selection(selection, window, cx); }); } }); @@ -707,6 +680,7 @@ pub struct AgentPanel { _base_view_observation: Option, _draft_editor_observation: Option, _thread_metadata_store_subscription: Subscription, + last_context_source: Option, } impl AgentPanel { @@ -1065,6 +1039,7 @@ impl AgentPanel { _base_view_observation: None, _draft_editor_observation: None, _thread_metadata_store_subscription, + last_context_source: None, }; // Initial sync of agent servers from extensions diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index 59a6cb4c924add..32f98d7fc57097 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -12,7 +12,7 @@ use anyhow::Result; use editor::{CompletionProvider, Editor, code_context_menus::COMPLETION_MENU_MAX_WIDTH}; use futures::FutureExt as _; use fuzzy::{PathMatch, StringMatch, StringMatchCandidate}; -use gpui::{App, BackgroundExecutor, Entity, SharedString, Task, WeakEntity}; +use gpui::{App, BackgroundExecutor, Entity, Focusable, SharedString, Task, WeakEntity, Window}; use language::{Buffer, CodeLabel, CodeLabelBuilder, HighlightId}; use lsp::CompletionContext; use multi_buffer::ToOffset as _; @@ -24,7 +24,7 @@ use project::{ }; use prompt_store::{PromptStore, UserPromptId}; use rope::Point; -use settings::{Settings, TerminalDockPosition}; +use settings::Settings; use terminal::terminal_settings::TerminalSettings; use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; use text::{Anchor, ToOffset as _, ToPoint as _}; @@ -35,11 +35,108 @@ use util::paths::PathStyle; use util::rel_path::RelPath; use util::truncate_and_remove_front; use workspace::Workspace; -use workspace::dock::DockPosition; use crate::AgentPanel; use crate::mention_set::MentionSet; +#[derive(Clone)] +pub(crate) enum AgentContextSelection { + Editor(Vec<(Entity, Range)>), + Terminal(Vec), +} + +#[derive(Clone)] +pub(crate) enum AgentContextSource { + Editor(WeakEntity), + TerminalView(WeakEntity), + TerminalPanel, +} + +impl AgentContextSource { + pub(crate) fn read_selection( + &self, + workspace: &Workspace, + include_current_line: bool, + cx: &mut App, + ) -> Option { + match self { + Self::Editor(handle) => { + let editor = handle.upgrade()?; + let ranges = editor_selection_ranges(&editor, include_current_line, cx); + (!ranges.is_empty()).then_some(AgentContextSelection::Editor(ranges)) + } + Self::TerminalView(handle) => { + let terminal_view = handle.upgrade()?; + terminal_view_selection(&terminal_view, cx) + .map(|text| AgentContextSelection::Terminal(vec![text])) + } + Self::TerminalPanel => { + let panel = workspace.panel::(cx)?; + let selections = panel.read(cx).terminal_selections(cx); + (!selections.is_empty()).then_some(AgentContextSelection::Terminal(selections)) + } + } + } + + pub(crate) fn from_focused(workspace: &Workspace, window: &Window, cx: &App) -> Option { + if let Some(agent_panel) = workspace.panel::(cx) + && agent_panel.focus_handle(cx).contains_focused(window, cx) + { + return None; + } + + if let Some(active_item) = workspace.active_item(cx) { + if let Some(editor) = active_item.act_as::(cx) { + if editor.focus_handle(cx).is_focused(window) { + return Some(Self::Editor(editor.downgrade())); + } + } else if let Some(terminal_view) = active_item.act_as::(cx) + && terminal_view.focus_handle(cx).is_focused(window) + { + return Some(Self::TerminalView(terminal_view.downgrade())); + } + } + + if let Some(panel) = workspace.panel::(cx) + && panel.focus_handle(cx).contains_focused(window, cx) + { + return Some(Self::TerminalPanel); + } + + None + } + + pub(crate) fn from_active(workspace: &Workspace, cx: &App) -> Option { + if let Some(active_item) = workspace.active_item(cx) { + if let Some(editor) = active_item.act_as::(cx) { + return Some(Self::Editor(editor.downgrade())); + } else if let Some(terminal_view) = active_item.act_as::(cx) { + return Some(Self::TerminalView(terminal_view.downgrade())); + } + } + if terminal_panel_dock_is_open(workspace, cx) { + return Some(Self::TerminalPanel); + } + None + } + + pub(crate) fn exists(&self, workspace: &Workspace, cx: &App) -> bool { + match self { + Self::Editor(handle) => handle.upgrade().is_some(), + Self::TerminalView(handle) => handle.upgrade().is_some(), + Self::TerminalPanel => terminal_panel_dock_is_open(workspace, cx), + } + } +} + +fn terminal_panel_dock_is_open(workspace: &Workspace, cx: &App) -> bool { + if workspace.panel::(cx).is_none() { + return false; + } + let position = TerminalSettings::get_global(cx).dock.into(); + workspace.dock_at_position(position).read(cx).is_open() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PromptContextEntry { Mode(PromptContextType), @@ -267,14 +364,13 @@ impl PromptCompletionProvider { // inserted confirm: Some(Arc::new(|_, _, _| true)), }), - PromptContextEntry::Action(action) => Self::completion_for_action( - action, - source_range, - editor, - mention_set, - workspace, - cx, - ), + PromptContextEntry::Action(action) => { + let selection = workspace.update(cx, |workspace, cx| { + AgentContextSource::from_active(workspace, cx)? + .read_selection(workspace, false, cx) + }); + Self::completion_for_action(action, source_range, editor, mention_set, selection) + } } } @@ -542,136 +638,27 @@ impl PromptCompletionProvider { source_range: Range, editor: WeakEntity, mention_set: WeakEntity, - workspace: &Entity, - cx: &mut App, + selection: Option, ) -> Option { let (new_text, on_action) = match action { - PromptContextAction::AddSelections => { - // Collect non-empty editor selections - let editor_selections: Vec<_> = selection_ranges(workspace, cx) - .into_iter() - .filter(|(buffer, range)| { - let snapshot = buffer.read(cx).snapshot(); - range.start.to_offset(&snapshot) != range.end.to_offset(&snapshot) - }) - .collect(); - - // Collect terminal selections from all terminal views if the terminal panel is visible - let terminal_selections: Vec = terminal_selections(workspace, cx); - - const EDITOR_PLACEHOLDER: &str = "selection "; - const TERMINAL_PLACEHOLDER: &str = "terminal "; - - let selections = editor_selections - .into_iter() - .enumerate() - .map(|(ix, (buffer, range))| { - ( - buffer, - range, - (EDITOR_PLACEHOLDER.len() * ix) - ..(EDITOR_PLACEHOLDER.len() * (ix + 1) - 1), - ) - }) - .collect::>(); - - let mut new_text: String = EDITOR_PLACEHOLDER.repeat(selections.len()); - - // Add terminal placeholders for each terminal selection - let terminal_ranges: Vec<(String, std::ops::Range)> = terminal_selections - .into_iter() - .map(|text| { - let start = new_text.len(); - new_text.push_str(TERMINAL_PLACEHOLDER); - (text, start..(new_text.len() - 1)) - }) - .collect(); - - let callback = Arc::new({ - let source_range = source_range.clone(); - move |_: CompletionIntent, window: &mut Window, cx: &mut App| { - let editor = editor.clone(); - let selections = selections.clone(); - let mention_set = mention_set.clone(); - let source_range = source_range.clone(); - let terminal_ranges = terminal_ranges.clone(); - window.defer(cx, move |window, cx| { - if let Some(editor) = editor.upgrade() { - // Insert editor selections - if !selections.is_empty() { - mention_set - .update(cx, |store, cx| { - store.confirm_mention_for_selection( - source_range.clone(), - selections, - editor.clone(), - window, - cx, - ) - }) - .ok(); - } - - // Insert terminal selections - for (terminal_text, terminal_range) in terminal_ranges { - let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); - let Some(start) = - snapshot.anchor_in_excerpt(source_range.start) - else { - return; - }; - let offset = start.to_offset(&snapshot); - - let line_count = terminal_text.lines().count() as u32; - let mention_uri = MentionUri::TerminalSelection { line_count }; - let range = snapshot.anchor_after(offset + terminal_range.start) - ..snapshot.anchor_after(offset + terminal_range.end); - - let crease = crate::mention_set::crease_for_mention( - mention_uri.name().into(), - mention_uri.icon_path(cx), - None, - range, - editor.downgrade(), - ); - - let crease_id = editor.update(cx, |editor, cx| { - let crease_ids = - editor.insert_creases(vec![crease.clone()], cx); - editor.fold_creases(vec![crease], false, window, cx); - crease_ids.first().copied().unwrap() - }); - - mention_set - .update(cx, |mention_set, _| { - mention_set.insert_mention( - crease_id, - mention_uri.clone(), - gpui::Task::ready(Ok( - crate::mention_set::Mention::Text { - content: terminal_text, - tracked_buffers: vec![], - }, - )) - .shared(), - ); - }) - .ok(); - } - } - }); - false - } - }); - - ( - new_text, - callback - as Arc< - dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync, - >, - ) - } + PromptContextAction::AddSelections => match selection? { + AgentContextSelection::Editor(editor_selections) => { + completion_text_for_editor_selections( + source_range.clone(), + editor, + mention_set, + editor_selections, + ) + } + AgentContextSelection::Terminal(terminal_selections) => { + completion_text_for_terminal_selections( + source_range.clone(), + editor, + mention_set, + terminal_selections, + ) + } + }, }; Some(Completion { @@ -1166,19 +1153,12 @@ impl PromptCompletionProvider { entries.push(PromptContextEntry::Mode(PromptContextType::Thread)); } - let has_editor_selection = workspace - .read(cx) - .active_item(cx) - .and_then(|item| item.downcast::()) - .is_some_and(|editor| { - editor.update(cx, |editor, cx| { - editor.has_non_empty_selection(&editor.display_snapshot(cx)) - }) - }); - - let has_terminal_selection = !terminal_selections(workspace, cx).is_empty(); - - if has_editor_selection || has_terminal_selection { + let has_active_selection = workspace.update(cx, |workspace, cx| { + AgentContextSource::from_active(workspace, cx) + .and_then(|source| source.read_selection(workspace, false, cx)) + .is_some() + }); + if has_active_selection { entries.push(PromptContextEntry::Action( PromptContextAction::AddSelections, )); @@ -2168,81 +2148,219 @@ fn build_code_label_for_path( label.build() } -fn terminal_selections(workspace: &Entity, cx: &App) -> Vec { - let mut selections = Vec::new(); - - // Check if the active item is a terminal (in a panel or not) - if let Some(terminal_view) = workspace +fn terminal_view_selection(terminal_view: &Entity, cx: &App) -> Option { + terminal_view .read(cx) - .active_item(cx) - .and_then(|item| item.act_as::(cx)) - { - if let Some(text) = terminal_view - .read(cx) - .terminal() - .read(cx) - .last_content - .selection_text - .clone() - .filter(|text| !text.is_empty()) - { - selections.push(text); - } - } - - if let Some(panel) = workspace.read(cx).panel::(cx) { - let position = match TerminalSettings::get_global(cx).dock { - TerminalDockPosition::Left => DockPosition::Left, - TerminalDockPosition::Bottom => DockPosition::Bottom, - TerminalDockPosition::Right => DockPosition::Right, - }; - let dock_is_open = workspace - .read(cx) - .dock_at_position(position) - .read(cx) - .is_open(); - if dock_is_open { - selections.extend(panel.read(cx).terminal_selections(cx)); - } - } - - selections + .terminal() + .read(cx) + .last_content + .selection_text + .clone() + .filter(|text| !text.is_empty()) } -fn selection_ranges( - workspace: &Entity, +fn editor_selection_ranges( + editor: &Entity, + include_current_line: bool, cx: &mut App, ) -> Vec<(Entity, Range)> { - let Some(editor) = workspace - .read(cx) - .active_item(cx) - .and_then(|item| item.act_as::(cx)) - else { - return Vec::new(); - }; - editor.update(cx, |editor, cx| { let selections = editor.selections.all_adjusted(&editor.display_snapshot(cx)); - let buffer = editor.buffer().clone().read(cx); - let snapshot = buffer.snapshot(cx); + let multi_buffer = editor.buffer().read(cx); + let multi_buffer_snapshot = multi_buffer.snapshot(cx); - selections - .into_iter() + let non_empty_rows: collections::HashSet = selections + .iter() .filter(|s| !s.is_empty()) - .map(|s| snapshot.anchor_after(s.start)..snapshot.anchor_before(s.end)) - .flat_map(|range| { - let (start_buffer, start) = buffer.text_anchor_for_position(range.start, cx)?; - let (end_buffer, end) = buffer.text_anchor_for_position(range.end, cx)?; + .flat_map(|s| s.start.row..=s.end.row) + .collect(); + + let mut seen_current_line_rows = collections::HashSet::default(); + let mut results = Vec::new(); + + for s in selections { + if s.is_empty() { + if !include_current_line + || non_empty_rows.contains(&s.start.row) + || !seen_current_line_rows.insert(s.start.row) + { + continue; + } + let Some((buffer, anchor)) = multi_buffer.text_anchor_for_position(s.start, cx) + else { + continue; + }; + let buffer_snapshot = buffer.read(cx).snapshot(); + let row = anchor.to_point(&buffer_snapshot).row; + let line_start = text::Point::new(row, 0); + let line_end = text::Point::new(row, buffer_snapshot.line_len(row)); + let start = buffer_snapshot.anchor_after(line_start); + let end = buffer_snapshot.anchor_before(line_end); + if start.to_offset(&buffer_snapshot) == end.to_offset(&buffer_snapshot) { + continue; + } + results.push((buffer, start..end)); + } else { + let mb_start = multi_buffer_snapshot.anchor_after(s.start); + let mb_end = multi_buffer_snapshot.anchor_before(s.end); + let Some((start_buffer, start)) = + multi_buffer.text_anchor_for_position(mb_start, cx) + else { + continue; + }; + let Some((end_buffer, end)) = multi_buffer.text_anchor_for_position(mb_end, cx) + else { + continue; + }; if start_buffer != end_buffer { - return None; + continue; } - Some((start_buffer, start..end)) - }) - .collect::>() + let buffer_snapshot = start_buffer.read(cx).snapshot(); + if start.to_offset(&buffer_snapshot) == end.to_offset(&buffer_snapshot) { + continue; + } + results.push((start_buffer, start..end)); + } + } + + results }) } +type ConfirmCallback = Arc bool + Send + Sync>; + +fn completion_text_for_editor_selections( + source_range: Range, + editor: WeakEntity, + mention_set: WeakEntity, + editor_selections: Vec<(Entity, Range)>, +) -> (String, ConfirmCallback) { + const EDITOR_PLACEHOLDER: &str = "selection "; + + let selections = editor_selections + .into_iter() + .enumerate() + .map(|(ix, (buffer, range))| { + ( + buffer, + range, + (EDITOR_PLACEHOLDER.len() * ix)..(EDITOR_PLACEHOLDER.len() * (ix + 1) - 1), + ) + }) + .collect::>(); + + let new_text = EDITOR_PLACEHOLDER.repeat(selections.len()); + + let callback: ConfirmCallback = Arc::new({ + move |_: CompletionIntent, window: &mut Window, cx: &mut App| { + let editor = editor.clone(); + let selections = selections.clone(); + let mention_set = mention_set.clone(); + let source_range = source_range.clone(); + window.defer(cx, move |window, cx| { + if let Some(editor) = editor.upgrade() + && !selections.is_empty() + { + mention_set + .update(cx, |store, cx| { + store.confirm_mention_for_selection( + source_range.clone(), + selections, + editor.clone(), + window, + cx, + ) + }) + .ok(); + } + }); + false + } + }); + + (new_text, callback) +} + +fn completion_text_for_terminal_selections( + source_range: Range, + editor: WeakEntity, + mention_set: WeakEntity, + terminal_selections: Vec, +) -> (String, ConfirmCallback) { + const TERMINAL_PLACEHOLDER: &str = "terminal "; + + let mut new_text = String::new(); + let terminal_ranges: Vec<(String, std::ops::Range)> = terminal_selections + .into_iter() + .map(|text| { + let start = new_text.len(); + new_text.push_str(TERMINAL_PLACEHOLDER); + (text, start..(new_text.len() - 1)) + }) + .collect(); + + let callback: ConfirmCallback = Arc::new({ + move |_: CompletionIntent, window: &mut Window, cx: &mut App| { + let editor = editor.clone(); + let mention_set = mention_set.clone(); + let source_range = source_range.clone(); + let terminal_ranges = terminal_ranges.clone(); + window.defer(cx, move |window, cx| { + let Some(editor) = editor.upgrade() else { + return; + }; + for (terminal_text, terminal_range) in terminal_ranges { + let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); + let Some(start) = snapshot.anchor_in_excerpt(source_range.start) else { + return; + }; + let offset = start.to_offset(&snapshot); + + let line_count = terminal_text.lines().count() as u32; + let mention_uri = MentionUri::TerminalSelection { line_count }; + let range = snapshot.anchor_after(offset + terminal_range.start) + ..snapshot.anchor_after(offset + terminal_range.end); + + let crease = crate::mention_set::crease_for_mention( + mention_uri.name().into(), + mention_uri.icon_path(cx), + None, + range, + editor.downgrade(), + ); + + let Some(crease_id) = editor.update(cx, |editor, cx| { + let crease_ids = editor.insert_creases(vec![crease.clone()], cx); + editor.fold_creases(vec![crease], false, window, cx); + crease_ids.first().copied() + }) else { + log::error!("insert_creases returned no ids for terminal selection"); + continue; + }; + + mention_set + .update(cx, |mention_set, _| { + mention_set.insert_mention( + crease_id, + mention_uri.clone(), + Task::ready(Ok(crate::mention_set::Mention::Text { + content: terminal_text, + tracked_buffers: vec![], + })) + .shared(), + ); + }) + .ok(); + } + }); + false + } + }); + + (new_text, callback) +} + #[cfg(test)] mod tests { use super::*; @@ -2652,4 +2770,71 @@ mod tests { "dir1/a.txt should be second" ); } + + #[gpui::test] + async fn test_source_read_selection_editor_whole_line(cx: &mut TestAppContext) { + use editor::Editor; + use project::Project; + use serde_json::json; + use text::ToOffset as _; + use util::path; + use workspace::{AppState, MultiWorkspace}; + + crate::conversation_view::tests::init_test(cx); + + let app_state = cx.update(AppState::test); + + app_state + .fs + .as_fake() + .insert_tree(path!("/root"), json!({ "a.txt": "" })) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + let buffer = cx.new(|cx| language::Buffer::local("abc\ndef\nghi", cx)); + let editor = + cx.new_window_entity(|window, cx| Editor::for_buffer(buffer.clone(), None, window, cx)); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([text::Point::new(1, 1)..text::Point::new(1, 1)]); + }); + }); + + let source = AgentContextSource::Editor(editor.downgrade()); + + workspace.update(cx, |workspace, cx| { + let selection = source + .read_selection(workspace, true, cx) + .expect("editor source with cursor on a line should yield a selection"); + assert!( + matches!(selection, AgentContextSelection::Editor(_)), + "expected Editor variant" + ); + if let AgentContextSelection::Editor(ranges) = selection { + assert_eq!( + ranges.len(), + 1, + "expected exactly one range for whole-line fallback" + ); + let (range_buffer, range) = &ranges[0]; + let snapshot = range_buffer.read(cx).snapshot(); + let start_offset = range.start.to_offset(&snapshot); + let end_offset = range.end.to_offset(&snapshot); + assert_eq!( + &snapshot.text()[start_offset..end_offset], + "def", + "whole-line fallback should capture the current row" + ); + } + + // With include_current_line = false and no non-empty selection, the + // fallback is suppressed and read_selection should return None. + assert!(source.read_selection(workspace, false, cx).is_none()); + }); + } } diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 9dd97975a184ee..00cc74a9b87db4 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -80,6 +80,7 @@ use crate::agent_connection_store::{ AgentConnectedState, AgentConnectionEntryEvent, AgentConnectionStore, }; use crate::agent_diff::AgentDiff; +use crate::completion_provider::AgentContextSelection; use crate::entry_view_state::{EntryViewEvent, ViewEvent}; use crate::message_editor::{InputAttempt, MessageEditor, MessageEditorEvent}; use crate::profile_selector::{ProfileProvider, ProfileSelector}; @@ -2760,11 +2761,16 @@ impl ConversationView { /// Inserts the selected text into the message editor or the message being /// edited, if any. - pub(crate) fn insert_selections(&self, window: &mut Window, cx: &mut Context) { + pub(crate) fn insert_selection( + &self, + selection: AgentContextSelection, + window: &mut Window, + cx: &mut Context, + ) { if let Some(active_thread) = self.active_thread() { active_thread.update(cx, |thread, cx| { thread.active_editor(cx).update(cx, |editor, cx| { - editor.insert_selections(window, cx); + editor.insert_selections(selection, window, cx); }) }); } @@ -2974,6 +2980,7 @@ pub(crate) mod tests { use workspace::{Item, MultiWorkspace}; use crate::agent_panel; + use crate::completion_provider::AgentContextSource; use crate::thread_metadata_store::ThreadMetadataStore; use super::*; @@ -5903,7 +5910,14 @@ pub(crate) mod tests { .and_then(|active| active.read(cx).editing_message), Some(0) ); - view.insert_selections(window, cx); + let workspace = workspace.upgrade().unwrap(); + let selection = workspace + .update(cx, |workspace, cx| { + AgentContextSource::from_active(workspace, cx)? + .read_selection(workspace, false, cx) + }) + .unwrap(); + view.insert_selection(selection, window, cx); }); user_message_editor.read_with(cx, |editor, cx| { @@ -5966,7 +5980,14 @@ pub(crate) mod tests { .and_then(|active| active.read(cx).editing_message), None ); - view.insert_selections(window, cx); + let workspace = view.workspace.upgrade().unwrap(); + let selection = workspace + .update(cx, |workspace, cx| { + AgentContextSource::from_active(workspace, cx)? + .read_selection(workspace, false, cx) + }) + .unwrap(); + view.insert_selection(selection, window, cx); }); message_editor.read_with(cx, |editor, cx| { diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index c6fd040f7e91e9..ec966f2af54899 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -3,8 +3,8 @@ use crate::SendImmediately; use crate::{ ChatWithFollow, completion_provider::{ - PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextAction, - PromptContextType, SlashCommandCompletion, + AgentContextSelection, PromptCompletionProvider, PromptCompletionProviderDelegate, + PromptContextAction, PromptContextType, SlashCommandCompletion, }, mention_set::{Mention, MentionImage, MentionSet, insert_crease_for_mention}, }; @@ -1365,7 +1365,12 @@ impl MessageEditor { .detach_and_log_err(cx); } - pub fn insert_selections(&mut self, window: &mut Window, cx: &mut Context) { + pub(crate) fn insert_selections( + &mut self, + selection: AgentContextSelection, + window: &mut Window, + cx: &mut Context, + ) { let editor = self.editor.read(cx); let editor_buffer = editor.buffer().read(cx); let Some(buffer) = editor_buffer.as_singleton() else { @@ -1376,17 +1381,13 @@ impl MessageEditor { let anchor = buffer.update(cx, |buffer, _cx| { buffer.anchor_before(cursor_offset.0.min(buffer.len())) }); - let Some(workspace) = self.workspace.upgrade() else { - return; - }; let Some(completion) = PromptCompletionProvider::::completion_for_action( PromptContextAction::AddSelections, anchor..anchor, self.editor.downgrade(), self.mention_set.downgrade(), - &workspace, - cx, + Some(selection), ) else { return; @@ -2010,7 +2011,7 @@ mod tests { use util::{path, paths::PathStyle, rel_path::rel_path}; use workspace::{AppState, Item, MultiWorkspace}; - use crate::completion_provider::PromptContextType; + use crate::completion_provider::{AgentContextSelection, PromptContextType}; use crate::{ conversation_view::tests::init_test, mention_set::insert_crease_for_mention, @@ -3731,11 +3732,17 @@ mod tests { }) }); - // Now let's insert the selection in the Agent Panel's editor and - // confirm that, after the insertion, the cursor is now in the visible - // range. + let text_editor_selection = editor.update(&mut cx, |editor, cx| { + let multibuffer = editor.buffer().read(cx); + let buffer = multibuffer.as_singleton().unwrap(); + let buffer_snapshot = buffer.read(cx).snapshot(); + let start = buffer_snapshot.anchor_before(0); + let end = buffer_snapshot.anchor_after(5); + AgentContextSelection::Editor(vec![(buffer, start..end)]) + }); + message_editor.update_in(&mut cx, |message_editor, window, cx| { - message_editor.insert_selections(window, cx); + message_editor.insert_selections(text_editor_selection, window, cx); }); cx.run_until_parked(); diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 4ad40b06e67616..34ec1eddcc8c4b 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -1539,11 +1539,7 @@ impl Focusable for TerminalPanel { impl Panel for TerminalPanel { fn position(&self, _window: &Window, cx: &App) -> DockPosition { - match TerminalSettings::get_global(cx).dock { - TerminalDockPosition::Left => DockPosition::Left, - TerminalDockPosition::Bottom => DockPosition::Bottom, - TerminalDockPosition::Right => DockPosition::Right, - } + TerminalSettings::get_global(cx).dock.into() } fn position_is_valid(&self, _: DockPosition) -> bool { diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index 1983b2921ffcc5..461726757d73f8 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -13,7 +13,7 @@ use gpui::{ px, }; use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsStore}; +use settings::{Settings, SettingsStore, TerminalDockPosition}; use std::sync::Arc; use ui::{ ContextMenu, CountBadge, Divider, DividerColor, IconButton, Tooltip, prelude::*, @@ -301,6 +301,16 @@ impl Into for DockPosition { } } +impl From for DockPosition { + fn from(value: TerminalDockPosition) -> Self { + match value { + TerminalDockPosition::Left => DockPosition::Left, + TerminalDockPosition::Bottom => DockPosition::Bottom, + TerminalDockPosition::Right => DockPosition::Right, + } + } +} + impl DockPosition { fn label(&self) -> &'static str { match self { From bd2fb74037b8a209c6707a613b1faca227606616 Mon Sep 17 00:00:00 2001 From: Mikhail Pertsev Date: Thu, 7 May 2026 15:02:11 +0200 Subject: [PATCH 14/33] editor: Extract `completions` and `code_actions` out of `editor.rs` (#56030) cc @SomeoneToIgnore ## Summary Follow-up to https://github.com/zed-industries/zed/discussions/55352, where the conclusion was to split `editor.rs` incrementally by topic instead of all at once. This mechanically extracts two editor topics into focused sibling modules: - `crates/editor/src/code_actions.rs` - `crates/editor/src/completions.rs` One odd boundary remains: `Editor::context_menu()` is still a general context-menu accessor, but it now lives in `code_actions.rs` because it was part of the moved code actions block and is also used by completions, Vim tests, agent UI, and the quick action bar. Would you prefer that generic context-menu accessor stay in `editor.rs` for now until context-menu code gets its own extraction? ## Testing - `cargo check -p editor --lib` - `cargo check -p editor --tests` - `cargo check -p editor --lib --features test-support` Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/editor/src/code_actions.rs | 523 +++++++ crates/editor/src/completions.rs | 1489 +++++++++++++++++++ crates/editor/src/editor.rs | 2228 ++--------------------------- 3 files changed, 2115 insertions(+), 2125 deletions(-) create mode 100644 crates/editor/src/code_actions.rs create mode 100644 crates/editor/src/completions.rs diff --git a/crates/editor/src/code_actions.rs b/crates/editor/src/code_actions.rs new file mode 100644 index 00000000000000..a5d33926d0c473 --- /dev/null +++ b/crates/editor/src/code_actions.rs @@ -0,0 +1,523 @@ +use super::*; + +impl Editor { + /// Toggles an action selection menu for the latest selection. + /// May show LSP code actions, code lens' command, runnables and potentially more entities applicable as actions. + /// Previous menu toggled with this method will be closed. + pub fn toggle_code_actions( + &mut self, + action: &ToggleCodeActions, + window: &mut Window, + cx: &mut Context, + ) { + let quick_launch = action.quick_launch; + let mut context_menu = self.context_menu.borrow_mut(); + if let Some(CodeContextMenu::CodeActions(code_actions)) = context_menu.as_ref() { + if code_actions.deployed_from == action.deployed_from { + // Toggle if we're selecting the same one + *context_menu = None; + cx.notify(); + return; + } else { + // Otherwise, clear it and start a new one + *context_menu = None; + cx.notify(); + } + } + drop(context_menu); + let snapshot = self.snapshot(window, cx); + let deployed_from = action.deployed_from.clone(); + let action = action.clone(); + self.completion_tasks.clear(); + self.discard_edit_prediction(EditPredictionDiscardReason::Ignored, cx); + + let multibuffer_point = match &action.deployed_from { + Some(CodeActionSource::Indicator(row)) | Some(CodeActionSource::RunMenu(row)) => { + DisplayPoint::new(*row, 0).to_point(&snapshot) + } + _ => self + .selections + .newest::(&snapshot.display_snapshot) + .head(), + }; + let Some((buffer, buffer_row)) = snapshot + .buffer_snapshot() + .buffer_line_for_row(MultiBufferRow(multibuffer_point.row)) + .and_then(|(buffer_snapshot, range)| { + self.buffer() + .read(cx) + .buffer(buffer_snapshot.remote_id()) + .map(|buffer| (buffer, range.start.row)) + }) + else { + return; + }; + let buffer_id = buffer.read(cx).remote_id(); + let tasks = self + .runnables + .runnables((buffer_id, buffer_row)) + .map(|t| Arc::new(t.to_owned())); + + let project = self.project.clone(); + let runnable_task = match deployed_from { + Some(CodeActionSource::Indicator(_)) => Task::ready(Ok(Default::default())), + _ => { + let mut task_context_task = Task::ready(Ok(None)); + let workspace = self.workspace().map(|w| w.downgrade()); + if let Some(tasks) = &tasks + && let Some(project) = project + { + task_context_task = + Self::build_tasks_context(&project, &buffer, buffer_row, tasks, cx); + } + + cx.spawn_in(window, { + let buffer = buffer.clone(); + async move |editor, cx| { + let task_context = match workspace { + Some(ws) => task_context_task + .await + .notify_workspace_async_err(ws, cx) + .flatten(), + None => task_context_task.await.ok().flatten(), + }; + + let resolved_tasks = + tasks + .zip(task_context.clone()) + .map(|(tasks, task_context)| ResolvedTasks { + templates: tasks.resolve(&task_context).collect(), + position: snapshot.buffer_snapshot().anchor_before(Point::new( + multibuffer_point.row, + tasks.column, + )), + }); + let debug_scenarios = editor + .update(cx, |editor, cx| { + editor.debug_scenarios(&resolved_tasks, &buffer, cx) + })? + .await; + anyhow::Ok((resolved_tasks, debug_scenarios, task_context)) + } + }) + } + }; + + let toggle_task = cx.spawn_in(window, async move |editor, cx| { + let (resolved_tasks, debug_scenarios, task_context) = runnable_task.await?; + + let code_actions = if let Some(CodeActionSource::RunMenu(_)) = &deployed_from { + None + } else { + editor.update(cx, |editor, _cx| match &editor.code_actions_for_selection { + CodeActionsForSelection::None => None, + CodeActionsForSelection::Fetching(task) => Some(task.clone()), + CodeActionsForSelection::Ready(action_fetch_ready) => { + Some(Task::ready(Some(action_fetch_ready.clone())).shared()) + } + })? + }; + let code_actions = match code_actions { + Some(code_actions) => code_actions + .await + .filter(|ActionFetchReady { location, .. }| { + let snapshot = location.buffer.read_with(cx, |buffer, _| buffer.snapshot()); + let point_range = location.range.to_point(&snapshot); + (point_range.start.row..=point_range.end.row).contains(&buffer_row) + }) + .map(|ActionFetchReady { actions, .. }| actions), + None => None, + }; + + editor.update_in(cx, |editor, window, cx| { + let spawn_straight_away = quick_launch + && resolved_tasks + .as_ref() + .is_some_and(|tasks| tasks.templates.len() == 1) + && code_actions + .as_ref() + .is_none_or(|actions| actions.is_empty()) + && debug_scenarios.is_empty(); + + crate::hover_popover::hide_hover(editor, cx); + let actions = CodeActionContents::new( + resolved_tasks, + code_actions, + debug_scenarios, + task_context.unwrap_or_default(), + ); + + // Don't show the menu if there are no actions available + if actions.is_empty() { + cx.notify(); + return Task::ready(Ok(())); + } + + *editor.context_menu.borrow_mut() = + Some(CodeContextMenu::CodeActions(CodeActionsMenu { + buffer, + actions, + selected_item: Default::default(), + scroll_handle: UniformListScrollHandle::default(), + deployed_from, + })); + cx.notify(); + if spawn_straight_away + && let Some(task) = editor.confirm_code_action( + &ConfirmCodeAction { item_ix: Some(0) }, + window, + cx, + ) + { + return task; + } + + Task::ready(Ok(())) + }) + }); + self.runnables_for_selection_toggle = cx.background_spawn(async move { + match toggle_task.await { + Ok(code_action_spawn) => match code_action_spawn.await { + Ok(()) => {} + Err(e) => log::error!("failed to spawn a toggled code action: {e:#}"), + }, + Err(e) => log::error!("failed to toggle code actions: {e:#}"), + } + }) + } + + pub fn confirm_code_action( + &mut self, + action: &ConfirmCodeAction, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + if self.read_only(cx) { + return None; + } + + let actions_menu = + if let CodeContextMenu::CodeActions(menu) = self.hide_context_menu(window, cx)? { + menu + } else { + return None; + }; + + let action_ix = action.item_ix.unwrap_or(actions_menu.selected_item); + let action = actions_menu.actions.get(action_ix)?; + let title = action.label(); + let buffer = actions_menu.buffer; + let workspace = self.workspace()?; + + match action { + CodeActionsItem::Task(task_source_kind, resolved_task) => { + workspace.update(cx, |workspace, cx| { + workspace.schedule_resolved_task( + task_source_kind, + resolved_task, + false, + window, + cx, + ); + + Some(Task::ready(Ok(()))) + }) + } + CodeActionsItem::CodeAction { action, provider } => { + if code_lens::try_handle_client_command(&action, self, &workspace, window, cx) { + return Some(Task::ready(Ok(()))); + } + + let apply_code_action = + provider.apply_code_action(buffer, action, true, window, cx); + let workspace = workspace.downgrade(); + Some(cx.spawn_in(window, async move |editor, cx| { + let project_transaction = apply_code_action.await?; + Self::open_project_transaction( + &editor, + workspace, + project_transaction, + title, + cx, + ) + .await + })) + } + CodeActionsItem::DebugScenario(scenario) => { + let context = actions_menu.actions.context.into(); + + workspace.update(cx, |workspace, cx| { + dap::send_telemetry(&scenario, TelemetrySpawnLocation::Gutter, cx); + workspace.start_debug_session( + scenario, + context, + Some(buffer), + None, + window, + cx, + ); + }); + Some(Task::ready(Ok(()))) + } + } + } + + pub fn code_actions_enabled_for_toolbar(&self, cx: &App) -> bool { + !self.code_action_providers.is_empty() + && EditorSettings::get_global(cx).toolbar.code_actions + } + + pub fn has_available_code_actions_for_selection(&self) -> bool { + if let CodeActionsForSelection::Ready(ready) = &self.code_actions_for_selection { + !ready.actions.is_empty() + } else { + false + } + } + + pub fn context_menu(&self) -> &RefCell> { + &self.context_menu + } + + pub(super) fn render_inline_code_actions( + &self, + icon_size: ui::IconSize, + display_row: DisplayRow, + is_active: bool, + cx: &mut Context, + ) -> AnyElement { + let show_tooltip = !self.context_menu_visible(); + IconButton::new("inline_code_actions", ui::IconName::BoltFilled) + .icon_size(icon_size) + .shape(ui::IconButtonShape::Square) + .icon_color(ui::Color::Hidden) + .toggle_state(is_active) + .when(show_tooltip, |this| { + this.tooltip({ + let focus_handle = self.focus_handle.clone(); + move |_window, cx| { + Tooltip::for_action_in( + "Toggle Code Actions", + &ToggleCodeActions { + deployed_from: None, + quick_launch: false, + }, + &focus_handle, + cx, + ) + } + }) + }) + .on_click(cx.listener(move |editor, _: &ClickEvent, window, cx| { + window.focus(&editor.focus_handle(cx), cx); + editor.toggle_code_actions( + &crate::actions::ToggleCodeActions { + deployed_from: Some(crate::actions::CodeActionSource::Indicator( + display_row, + )), + quick_launch: false, + }, + window, + cx, + ); + })) + .into_any_element() + } + + pub(super) fn refresh_code_actions_for_selection( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + self.code_actions_for_selection = CodeActionsForSelection::Fetching( + cx.spawn_in(window, async move |editor, cx| { + cx.background_executor() + .timer(CODE_ACTIONS_DEBOUNCE_TIMEOUT) + .await; + + let (start_buffer, start, _, end, _newest_selection) = editor + .update(cx, |editor, cx| { + let newest_selection = editor.selections.newest_anchor().clone(); + if newest_selection.head().diff_base_anchor().is_some() { + return None; + } + let display_snapshot = editor.display_snapshot(cx); + let newest_selection_adjusted = + editor.selections.newest_adjusted(&display_snapshot); + let buffer = editor.buffer.read(cx); + + let (start_buffer, start) = + buffer.text_anchor_for_position(newest_selection_adjusted.start, cx)?; + let (end_buffer, end) = + buffer.text_anchor_for_position(newest_selection_adjusted.end, cx)?; + + Some((start_buffer, start, end_buffer, end, newest_selection)) + }) + .ok() + .flatten() + .filter(|(start_buffer, _, end_buffer, _, _)| start_buffer == end_buffer)?; + + let (providers, tasks) = editor + .update_in(cx, |editor, window, cx| { + let providers = editor.code_action_providers.clone(); + let tasks = editor + .code_action_providers + .iter() + .map(|provider| { + provider.code_actions(&start_buffer, start..end, window, cx) + }) + .collect::>(); + (providers, tasks) + }) + .ok()?; + + let mut actions = Vec::new(); + for (provider, provider_actions) in + providers.into_iter().zip(future::join_all(tasks).await) + { + if let Some(provider_actions) = provider_actions.log_err() { + actions.extend(provider_actions.into_iter().map(|action| { + AvailableCodeAction { + action, + provider: provider.clone(), + } + })); + } + } + + editor + .update(cx, |editor, cx| { + let new_actions = if actions.is_empty() { + editor.code_actions_for_selection = CodeActionsForSelection::None; + None + } else { + let new_actions = ActionFetchReady { + location: Location { + buffer: start_buffer, + range: start..end, + }, + actions: Rc::from(actions), + }; + editor.code_actions_for_selection = + CodeActionsForSelection::Ready(new_actions.clone()); + Some(new_actions) + }; + cx.notify(); + new_actions + }) + .ok() + .flatten() + }) + .shared(), + ); + } + + fn debug_scenarios( + &mut self, + resolved_tasks: &Option, + buffer: &Entity, + cx: &mut App, + ) -> Task> { + maybe!({ + let project = self.project()?; + let dap_store = project.read(cx).dap_store(); + let mut scenarios = vec![]; + let resolved_tasks = resolved_tasks.as_ref()?; + let buffer = buffer.read(cx); + let language = buffer.language()?; + let debug_adapter = LanguageSettings::for_buffer(&buffer, cx) + .debuggers + .first() + .map(SharedString::from) + .or_else(|| language.config().debuggers.first().map(SharedString::from))?; + + dap_store.update(cx, |dap_store, cx| { + for (_, task) in &resolved_tasks.templates { + let maybe_scenario = dap_store.debug_scenario_for_build_task( + task.original_task().clone(), + debug_adapter.clone().into(), + task.display_label().to_owned().into(), + cx, + ); + scenarios.push(maybe_scenario); + } + }); + Some(cx.background_spawn(async move { + futures::future::join_all(scenarios) + .await + .into_iter() + .flatten() + .collect::>() + })) + }) + .unwrap_or_else(|| Task::ready(vec![])) + } +} + +pub trait CodeActionProvider { + fn id(&self) -> Arc; + + fn code_actions( + &self, + buffer: &Entity, + range: Range, + window: &mut Window, + cx: &mut App, + ) -> Task>>; + + fn apply_code_action( + &self, + buffer_handle: Entity, + action: CodeAction, + push_to_history: bool, + window: &mut Window, + cx: &mut App, + ) -> Task>; +} + +impl CodeActionProvider for Entity { + fn id(&self) -> Arc { + "project".into() + } + + fn code_actions( + &self, + buffer: &Entity, + range: Range, + _window: &mut Window, + cx: &mut App, + ) -> Task>> { + self.update(cx, |project, cx| { + let code_lens_actions = if EditorSettings::get_global(cx).code_lens.show_in_menu() { + Some(project.code_lens_actions(buffer, range.clone(), cx)) + } else { + None + }; + let code_actions = project.code_actions(buffer, range, None, cx); + cx.background_spawn(async move { + let code_lens_actions = match code_lens_actions { + Some(task) => task.await.context("code lens fetch")?.unwrap_or_default(), + None => Vec::new(), + }; + let code_actions = code_actions + .await + .context("code action fetch")? + .unwrap_or_default(); + Ok(code_lens_actions.into_iter().chain(code_actions).collect()) + }) + }) + } + + fn apply_code_action( + &self, + buffer_handle: Entity, + action: CodeAction, + push_to_history: bool, + _window: &mut Window, + cx: &mut App, + ) -> Task> { + self.update(cx, |project, cx| { + project.apply_code_action(buffer_handle, action, push_to_history, cx) + }) + } +} diff --git a/crates/editor/src/completions.rs b/crates/editor/src/completions.rs new file mode 100644 index 00000000000000..2be7f28c5bf6fc --- /dev/null +++ b/crates/editor/src/completions.rs @@ -0,0 +1,1489 @@ +use super::*; + +impl Editor { + pub fn set_completion_provider(&mut self, provider: Option>) { + self.completion_provider = provider; + } + + pub fn set_show_completions_on_input(&mut self, show_completions_on_input: Option) { + self.show_completions_on_input_override = show_completions_on_input; + } + + pub fn text_layout_details(&self, window: &mut Window, cx: &mut App) -> TextLayoutDetails { + TextLayoutDetails { + text_system: window.text_system().clone(), + editor_style: self.style.clone().unwrap_or_else(|| self.create_style(cx)), + rem_size: window.rem_size(), + scroll_anchor: self.scroll_manager.shared_scroll_anchor(cx), + visible_rows: self.visible_line_count(), + vertical_scroll_margin: self.scroll_manager.vertical_scroll_margin, + } + } + + pub fn show_word_completions( + &mut self, + _: &ShowWordCompletions, + window: &mut Window, + cx: &mut Context, + ) { + self.open_or_update_completions_menu( + Some(CompletionsMenuSource::Words { + ignore_threshold: true, + }), + None, + false, + window, + cx, + ); + } + + pub fn show_completions( + &mut self, + _: &ShowCompletions, + window: &mut Window, + cx: &mut Context, + ) { + self.open_or_update_completions_menu(None, None, false, window, cx); + } + + pub fn confirm_completion( + &mut self, + action: &ConfirmCompletion, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + if self.read_only(cx) { + return None; + } + self.do_completion(action.item_ix, CompletionIntent::Complete, window, cx) + } + + pub fn confirm_completion_insert( + &mut self, + _: &ConfirmCompletionInsert, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + if self.read_only(cx) { + return None; + } + self.do_completion(None, CompletionIntent::CompleteWithInsert, window, cx) + } + + pub fn confirm_completion_replace( + &mut self, + _: &ConfirmCompletionReplace, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + if self.read_only(cx) { + return None; + } + self.do_completion(None, CompletionIntent::CompleteWithReplace, window, cx) + } + + pub fn compose_completion( + &mut self, + action: &ComposeCompletion, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + self.do_completion(action.item_ix, CompletionIntent::Compose, window, cx) + } + + pub fn has_visible_completions_menu(&self) -> bool { + !self.edit_prediction_preview_is_active() + && self.context_menu.borrow().as_ref().is_some_and(|menu| { + menu.visible() && matches!(menu, CodeContextMenu::Completions(_)) + }) + } + + pub(super) fn trigger_completion_on_input( + &mut self, + text: &str, + trigger_in_words: bool, + window: &mut Window, + cx: &mut Context, + ) { + let completions_source = self + .context_menu + .borrow() + .as_ref() + .and_then(|menu| match menu { + CodeContextMenu::Completions(completions_menu) => Some(completions_menu.source), + CodeContextMenu::CodeActions(_) => None, + }); + + match completions_source { + Some(CompletionsMenuSource::Words { .. }) => { + self.open_or_update_completions_menu( + Some(CompletionsMenuSource::Words { + ignore_threshold: false, + }), + None, + trigger_in_words, + window, + cx, + ); + } + _ => self.open_or_update_completions_menu( + None, + Some(text.to_owned()).filter(|x| !x.is_empty()), + trigger_in_words, + window, + cx, + ), + } + } + + pub(super) fn is_lsp_relevant(&self, file: Option<&Arc>, cx: &App) -> bool { + let Some(project) = self.project() else { + return false; + }; + let Some(buffer_file) = project::File::from_dyn(file) else { + return false; + }; + let Some(entry_id) = buffer_file.project_entry_id() else { + return false; + }; + let project = project.read(cx); + let Some(buffer_worktree) = project.worktree_for_id(buffer_file.worktree_id(cx), cx) else { + return false; + }; + let Some(worktree_entry) = buffer_worktree.read(cx).entry_for_id(entry_id) else { + return false; + }; + !worktree_entry.is_ignored + } + + pub(super) fn visible_buffers(&self, cx: &mut Context) -> Vec> { + let display_snapshot = self.display_snapshot(cx); + let visible_range = self.multi_buffer_visible_range(&display_snapshot, cx); + let multi_buffer = self.buffer().read(cx); + display_snapshot + .buffer_snapshot() + .range_to_buffer_ranges(visible_range) + .into_iter() + .filter(|(_, excerpt_visible_range, _)| !excerpt_visible_range.is_empty()) + .filter_map(|(buffer_snapshot, _, _)| multi_buffer.buffer(buffer_snapshot.remote_id())) + .collect() + } + + pub(super) fn visible_buffer_ranges( + &self, + cx: &mut Context, + ) -> Vec<( + BufferSnapshot, + Range, + ExcerptRange, + )> { + let display_snapshot = self.display_snapshot(cx); + let visible_range = self.multi_buffer_visible_range(&display_snapshot, cx); + display_snapshot + .buffer_snapshot() + .range_to_buffer_ranges(visible_range) + .into_iter() + .filter(|(_, excerpt_visible_range, _)| !excerpt_visible_range.is_empty()) + .collect() + } + + pub(super) fn trigger_on_type_formatting( + &self, + input: String, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + if input.chars().count() != 1 { + return None; + } + + let project = self.project()?; + let position = self.selections.newest_anchor().head(); + let (buffer, buffer_position) = self + .buffer + .read(cx) + .text_anchor_for_position(position, cx)?; + + let settings = LanguageSettings::for_buffer_at(&buffer.read(cx), buffer_position, cx); + if !settings.use_on_type_format { + return None; + } + + // OnTypeFormatting returns a list of edits, no need to pass them between Zed instances, + // hence we do LSP request & edit on host side only — add formats to host's history. + let push_to_lsp_host_history = true; + // If this is not the host, append its history with new edits. + let push_to_client_history = project.read(cx).is_via_collab(); + + let on_type_formatting = project.update(cx, |project, cx| { + project.on_type_format( + buffer.clone(), + buffer_position, + input, + push_to_lsp_host_history, + cx, + ) + }); + Some(cx.spawn_in(window, async move |editor, cx| { + if let Some(transaction) = on_type_formatting.await? { + if push_to_client_history { + buffer.update(cx, |buffer, _| { + buffer.push_transaction(transaction, Instant::now()); + buffer.finalize_last_transaction(); + }); + } + editor.update(cx, |editor, cx| { + editor.refresh_document_highlights(cx); + })?; + } + Ok(()) + })) + } + + pub(super) fn open_or_update_completions_menu( + &mut self, + requested_source: Option, + trigger: Option, + trigger_in_words: bool, + window: &mut Window, + cx: &mut Context, + ) { + if self.pending_rename.is_some() { + return; + } + + let completions_source = self + .context_menu + .borrow() + .as_ref() + .and_then(|menu| match menu { + CodeContextMenu::Completions(completions_menu) => Some(completions_menu.source), + CodeContextMenu::CodeActions(_) => None, + }); + + let multibuffer_snapshot = self.buffer.read(cx).read(cx); + + // Typically `start` == `end`, but with snippet tabstop choices the default choice is + // inserted and selected. To handle that case, the start of the selection is used so that + // the menu starts with all choices. + let position = self + .selections + .newest_anchor() + .start + .bias_right(&multibuffer_snapshot); + + if position.diff_base_anchor().is_some() { + return; + } + let multibuffer_position = multibuffer_snapshot.anchor_before(position); + let Some((buffer_position, _)) = + multibuffer_snapshot.anchor_to_buffer_anchor(multibuffer_position) + else { + return; + }; + let Some(buffer) = self.buffer.read(cx).buffer(buffer_position.buffer_id) else { + return; + }; + let buffer_snapshot = buffer.read(cx).snapshot(); + + let menu_is_open = matches!( + self.context_menu.borrow().as_ref(), + Some(CodeContextMenu::Completions(_)) + ); + + let language = buffer_snapshot + .language_at(buffer_position) + .map(|language| language.name()); + let language_settings = multibuffer_snapshot.language_settings_at(multibuffer_position, cx); + let completion_settings = language_settings.completions.clone(); + + let show_completions_on_input = self + .show_completions_on_input_override + .unwrap_or(language_settings.show_completions_on_input); + if !menu_is_open && trigger.is_some() && !show_completions_on_input { + return; + } + + let query: Option> = + Self::completion_query(&multibuffer_snapshot, multibuffer_position) + .map(|query| query.into()); + + drop(multibuffer_snapshot); + + // Hide the current completions menu when query is empty. Without this, cached + // completions from before the trigger char may be reused (#32774). + if query.is_none() && menu_is_open { + self.hide_context_menu(window, cx); + } + + let mut ignore_word_threshold = false; + let provider = match requested_source { + Some(CompletionsMenuSource::Normal) | None => self.completion_provider.clone(), + Some(CompletionsMenuSource::Words { ignore_threshold }) => { + ignore_word_threshold = ignore_threshold; + None + } + Some(CompletionsMenuSource::SnippetChoices) + | Some(CompletionsMenuSource::SnippetsOnly) => { + log::error!("bug: SnippetChoices requested_source is not handled"); + None + } + }; + + let sort_completions = provider + .as_ref() + .is_some_and(|provider| provider.sort_completions()); + + let filter_completions = provider + .as_ref() + .is_none_or(|provider| provider.filter_completions()); + + let was_snippets_only = matches!( + completions_source, + Some(CompletionsMenuSource::SnippetsOnly) + ); + + if let Some(CodeContextMenu::Completions(menu)) = self.context_menu.borrow_mut().as_mut() { + if filter_completions { + menu.filter( + query.clone().unwrap_or_default(), + buffer_position, + &buffer, + provider.clone(), + window, + cx, + ); + } + // When `is_incomplete` is false, no need to re-query completions when the current query + // is a suffix of the initial query. + let was_complete = !menu.is_incomplete; + if was_complete && !was_snippets_only { + // If the new query is a suffix of the old query (typing more characters) and + // the previous result was complete, the existing completions can be filtered. + // + // Note that snippet completions are always complete. + let query_matches = match (&menu.initial_query, &query) { + (Some(initial_query), Some(query)) => query.starts_with(initial_query.as_ref()), + (None, _) => true, + _ => false, + }; + if query_matches { + let position_matches = if menu.initial_position == position { + true + } else { + let snapshot = self.buffer.read(cx).read(cx); + menu.initial_position.to_offset(&snapshot) == position.to_offset(&snapshot) + }; + if position_matches { + return; + } + } + } + }; + + let (word_replace_range, word_to_exclude) = if let (word_range, Some(CharKind::Word)) = + buffer_snapshot.surrounding_word(buffer_position, None) + { + let word_to_exclude = buffer_snapshot + .text_for_range(word_range.clone()) + .collect::(); + ( + buffer_snapshot.anchor_before(word_range.start) + ..buffer_snapshot.anchor_after(buffer_position), + Some(word_to_exclude), + ) + } else { + (buffer_position..buffer_position, None) + }; + + let show_completion_documentation = buffer_snapshot + .settings_at(buffer_position, cx) + .show_completion_documentation; + + // The document can be large, so stay in reasonable bounds when searching for words, + // otherwise completion pop-up might be slow to appear. + const WORD_LOOKUP_ROWS: u32 = 5_000; + let buffer_row = text::ToPoint::to_point(&buffer_position, &buffer_snapshot).row; + let min_word_search = buffer_snapshot.clip_point( + Point::new(buffer_row.saturating_sub(WORD_LOOKUP_ROWS), 0), + Bias::Left, + ); + let max_word_search = buffer_snapshot.clip_point( + Point::new(buffer_row + WORD_LOOKUP_ROWS, 0).min(buffer_snapshot.max_point()), + Bias::Right, + ); + let word_search_range = buffer_snapshot.point_to_offset(min_word_search) + ..buffer_snapshot.point_to_offset(max_word_search); + + let skip_digits = query + .as_ref() + .is_none_or(|query| !query.chars().any(|c| c.is_digit(10))); + + let load_provider_completions = provider.as_ref().is_some_and(|provider| { + trigger.as_ref().is_none_or(|trigger| { + provider.is_completion_trigger( + &buffer, + buffer_position, + trigger, + trigger_in_words, + cx, + ) + }) + }); + + let provider_responses = if let Some(provider) = &provider + && load_provider_completions + { + let trigger_character = trigger + .as_ref() + .filter(|trigger| { + buffer + .read(cx) + .completion_triggers() + .contains(trigger.as_str()) + }) + .cloned(); + let completion_context = CompletionContext { + trigger_kind: match &trigger_character { + Some(_) => CompletionTriggerKind::TRIGGER_CHARACTER, + None => CompletionTriggerKind::INVOKED, + }, + trigger_character, + }; + + provider.completions(&buffer, buffer_position, completion_context, window, cx) + } else { + Task::ready(Ok(Vec::new())) + }; + + let load_word_completions = if !self.word_completions_enabled { + false + } else if requested_source + == Some(CompletionsMenuSource::Words { + ignore_threshold: true, + }) + { + true + } else { + load_provider_completions + && completion_settings.words != WordsCompletionMode::Disabled + && (ignore_word_threshold || { + let words_min_length = completion_settings.words_min_length; + // check whether word has at least `words_min_length` characters + let query_chars = query.iter().flat_map(|q| q.chars()); + query_chars.take(words_min_length).count() == words_min_length + }) + }; + + let mut words = if load_word_completions { + cx.background_spawn({ + let buffer_snapshot = buffer_snapshot.clone(); + async move { + buffer_snapshot.words_in_range(WordsQuery { + fuzzy_contents: None, + range: word_search_range, + skip_digits, + }) + } + }) + } else { + Task::ready(BTreeMap::default()) + }; + + let snippet_char_classifier = buffer_snapshot + .char_classifier_at(buffer_position) + .scope_context(Some(CharScopeContext::Completion)); + + let snippets = if let Some(provider) = &provider + && provider.show_snippets() + && let Some(project) = self.project() + { + let word_trigger = trigger.as_ref().is_some_and(|trigger| { + !trigger.is_empty() + && trigger + .chars() + .all(|character| snippet_char_classifier.is_word(character)) + }); + let requires_strong_snippet_match = !menu_is_open && !trigger_in_words && word_trigger; + let load_snippet_completions = !requires_strong_snippet_match + || query.as_ref().is_some_and(|query| { + let project = project.read(cx); + has_strong_snippet_prefix_match( + &project, + &buffer, + buffer_position, + &snippet_char_classifier, + query, + cx, + ) + }); + + if load_snippet_completions { + project.update(cx, |project, cx| { + snippet_completions( + project, + &buffer, + buffer_position, + snippet_char_classifier, + cx, + ) + }) + } else { + Task::ready(Ok(CompletionResponse { + completions: Vec::new(), + display_options: Default::default(), + is_incomplete: false, + })) + } + } else { + Task::ready(Ok(CompletionResponse { + completions: Vec::new(), + display_options: Default::default(), + is_incomplete: false, + })) + }; + + let snippet_sort_order = EditorSettings::get_global(cx).snippet_sort_order; + + let id = post_inc(&mut self.next_completion_id); + let task = cx.spawn_in(window, async move |editor, cx| { + let Ok(()) = editor.update(cx, |this, _| { + this.completion_tasks.retain(|(task_id, _)| *task_id >= id); + }) else { + return; + }; + + // TODO: Ideally completions from different sources would be selectively re-queried, so + // that having one source with `is_incomplete: true` doesn't cause all to be re-queried. + let mut completions = Vec::new(); + let mut is_incomplete = false; + let mut display_options: Option = None; + if let Some(provider_responses) = provider_responses.await.log_err() + && !provider_responses.is_empty() + { + for response in provider_responses { + completions.extend(response.completions); + is_incomplete = is_incomplete || response.is_incomplete; + match display_options.as_mut() { + None => { + display_options = Some(response.display_options); + } + Some(options) => options.merge(&response.display_options), + } + } + if completion_settings.words == WordsCompletionMode::Fallback { + words = Task::ready(BTreeMap::default()); + } + } + let display_options = display_options.unwrap_or_default(); + + let mut words = words.await; + if let Some(word_to_exclude) = &word_to_exclude { + words.remove(word_to_exclude); + } + for lsp_completion in &completions { + words.remove(&lsp_completion.new_text); + } + completions.extend(words.into_iter().map(|(word, word_range)| Completion { + replace_range: word_replace_range.clone(), + new_text: word.clone(), + label: CodeLabel::plain(word, None), + match_start: None, + snippet_deduplication_key: None, + icon_path: None, + documentation: None, + source: CompletionSource::BufferWord { + word_range, + resolved: false, + }, + insert_text_mode: Some(InsertTextMode::AS_IS), + confirm: None, + })); + + completions.extend( + snippets + .await + .into_iter() + .flat_map(|response| response.completions), + ); + + let menu = if completions.is_empty() { + None + } else { + let Ok((mut menu, matches_task)) = editor.update(cx, |editor, cx| { + let languages = editor + .workspace + .as_ref() + .and_then(|(workspace, _)| workspace.upgrade()) + .map(|workspace| workspace.read(cx).app_state().languages.clone()); + let menu = CompletionsMenu::new( + id, + requested_source.unwrap_or(if load_provider_completions { + CompletionsMenuSource::Normal + } else { + CompletionsMenuSource::SnippetsOnly + }), + sort_completions, + show_completion_documentation, + position, + query.clone(), + is_incomplete, + buffer.clone(), + completions.into(), + editor + .context_menu() + .borrow_mut() + .as_ref() + .map(|menu| menu.primary_scroll_handle()), + display_options, + snippet_sort_order, + languages, + language, + cx, + ); + + let query = if filter_completions { query } else { None }; + let matches_task = menu.do_async_filtering( + query.unwrap_or_default(), + buffer_position, + &buffer, + cx, + ); + (menu, matches_task) + }) else { + return; + }; + + let matches = matches_task.await; + + let Ok(()) = editor.update_in(cx, |editor, window, cx| { + // Newer menu already set, so exit. + if let Some(CodeContextMenu::Completions(prev_menu)) = + editor.context_menu.borrow().as_ref() + && prev_menu.id > id + { + return; + }; + + // Only valid to take prev_menu because either the new menu is immediately set + // below, or the menu is hidden. + if let Some(CodeContextMenu::Completions(prev_menu)) = + editor.context_menu.borrow_mut().take() + { + let position_matches = + if prev_menu.initial_position == menu.initial_position { + true + } else { + let snapshot = editor.buffer.read(cx).read(cx); + prev_menu.initial_position.to_offset(&snapshot) + == menu.initial_position.to_offset(&snapshot) + }; + if position_matches { + // Preserve markdown cache before `set_filter_results` because it will + // try to populate the documentation cache. + menu.preserve_markdown_cache(prev_menu); + } + }; + + menu.set_filter_results(matches, provider, window, cx); + }) else { + return; + }; + + menu.visible().then_some(menu) + }; + + editor + .update_in(cx, |editor, window, cx| { + if editor.focus_handle.is_focused(window) + && let Some(menu) = menu + { + *editor.context_menu.borrow_mut() = + Some(CodeContextMenu::Completions(menu)); + + crate::hover_popover::hide_hover(editor, cx); + if editor.show_edit_predictions_in_menu() { + editor.update_visible_edit_prediction(window, cx); + } else { + editor + .discard_edit_prediction(EditPredictionDiscardReason::Ignored, cx); + } + + cx.notify(); + return; + } + + if editor.completion_tasks.len() <= 1 { + // If there are no more completion tasks and the last menu was empty, we should hide it. + let was_hidden = editor.hide_context_menu(window, cx).is_none(); + // If it was already hidden and we don't show edit predictions in the menu, + // we should also show the edit prediction when available. + if was_hidden && editor.show_edit_predictions_in_menu() { + editor.update_visible_edit_prediction(window, cx); + } + } + }) + .ok(); + }); + + self.completion_tasks.push((id, task)); + } + + pub(super) fn with_completions_menu_matching_id( + &self, + id: CompletionId, + f: impl FnOnce(Option<&mut CompletionsMenu>) -> R, + ) -> R { + let mut context_menu = self.context_menu.borrow_mut(); + let Some(CodeContextMenu::Completions(completions_menu)) = &mut *context_menu else { + return f(None); + }; + if completions_menu.id != id { + return f(None); + } + f(Some(completions_menu)) + } + + fn completion_query(buffer: &MultiBufferSnapshot, position: impl ToOffset) -> Option { + let offset = position.to_offset(buffer); + let (word_range, kind) = + buffer.surrounding_word(offset, Some(CharScopeContext::Completion)); + if offset > word_range.start && kind == Some(CharKind::Word) { + Some( + buffer + .text_for_range(word_range.start..offset) + .collect::(), + ) + } else { + None + } + } + + fn do_completion( + &mut self, + item_ix: Option, + intent: CompletionIntent, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + use language::ToOffset as _; + + let CodeContextMenu::Completions(completions_menu) = self.hide_context_menu(window, cx)? + else { + return None; + }; + + let candidate_id = { + let entries = completions_menu.entries.borrow(); + let mat = entries.get(item_ix.unwrap_or(completions_menu.selected_item))?; + if self.show_edit_predictions_in_menu() { + self.discard_edit_prediction(EditPredictionDiscardReason::Rejected, cx); + } + mat.candidate_id + }; + + let completion = completions_menu + .completions + .borrow() + .get(candidate_id)? + .clone(); + cx.stop_propagation(); + + let buffer_handle = completions_menu.buffer.clone(); + let multibuffer_snapshot = self.buffer.read(cx).snapshot(cx); + let (initial_position, _) = + multibuffer_snapshot.anchor_to_buffer_anchor(completions_menu.initial_position)?; + + let CompletionEdit { + new_text, + snippet, + replace_range, + } = process_completion_for_edit(&completion, intent, &buffer_handle, &initial_position, cx); + + let buffer = buffer_handle.read(cx).snapshot(); + let newest_selection = self.selections.newest_anchor(); + + let Some(replace_range_multibuffer) = + multibuffer_snapshot.buffer_anchor_range_to_anchor_range(replace_range.clone()) + else { + return None; + }; + + let Some((buffer_snapshot, newest_range_buffer)) = + multibuffer_snapshot.anchor_range_to_buffer_anchor_range(newest_selection.range()) + else { + return None; + }; + + let old_text = buffer + .text_for_range(replace_range.clone()) + .collect::(); + let lookbehind = newest_range_buffer + .start + .to_offset(buffer_snapshot) + .saturating_sub(replace_range.start.to_offset(&buffer_snapshot)); + let lookahead = replace_range + .end + .to_offset(&buffer_snapshot) + .saturating_sub(newest_range_buffer.end.to_offset(&buffer)); + let prefix = &old_text[..old_text.len().saturating_sub(lookahead)]; + let suffix = &old_text[lookbehind.min(old_text.len())..]; + + let selections = self + .selections + .all::(&self.display_snapshot(cx)); + let mut ranges = Vec::new(); + let mut all_commit_ranges = Vec::new(); + let mut linked_edits = LinkedEdits::new(); + + let text: Arc = new_text.clone().into(); + for selection in &selections { + let range = if selection.id == newest_selection.id { + replace_range_multibuffer.clone() + } else { + let mut range = selection.range(); + + // if prefix is present, don't duplicate it + if multibuffer_snapshot + .contains_str_at(range.start.saturating_sub_usize(lookbehind), prefix) + { + range.start = range.start.saturating_sub_usize(lookbehind); + + // if suffix is also present, mimic the newest cursor and replace it + if selection.id != newest_selection.id + && multibuffer_snapshot.contains_str_at(range.end, suffix) + { + range.end += lookahead; + } + } + range.to_anchors(&multibuffer_snapshot) + }; + + ranges.push(range.clone()); + + let start_anchor = multibuffer_snapshot.anchor_before(range.start); + let end_anchor = multibuffer_snapshot.anchor_after(range.end); + + if let Some((buffer_snapshot_2, anchor_range)) = + multibuffer_snapshot.anchor_range_to_buffer_anchor_range(start_anchor..end_anchor) + && buffer_snapshot_2.remote_id() == buffer_snapshot.remote_id() + { + all_commit_ranges.push(anchor_range.clone()); + if !self.linked_edit_ranges.is_empty() { + linked_edits.push(&self, anchor_range, text.clone(), cx); + } + } + } + + let common_prefix_len = old_text + .chars() + .zip(new_text.chars()) + .take_while(|(a, b)| a == b) + .map(|(a, _)| a.len_utf8()) + .sum::(); + + cx.emit(EditorEvent::InputHandled { + utf16_range_to_replace: None, + text: new_text[common_prefix_len..].into(), + }); + + let tx_id = self.transact(window, cx, |editor, window, cx| { + if let Some(mut snippet) = snippet { + snippet.text = new_text.to_string(); + let offset_ranges = ranges + .iter() + .map(|range| range.to_offset(&multibuffer_snapshot)) + .collect::>(); + editor + .insert_snippet(&offset_ranges, snippet, window, cx) + .log_err(); + } else { + editor.buffer.update(cx, |multi_buffer, cx| { + let auto_indent = match completion.insert_text_mode { + Some(InsertTextMode::AS_IS) => None, + _ => editor.autoindent_mode.clone(), + }; + let edits = ranges.into_iter().map(|range| (range, new_text.as_str())); + multi_buffer.edit(edits, auto_indent, cx); + }); + } + linked_edits.apply(cx); + editor.refresh_edit_prediction(true, false, window, cx); + }); + self.invalidate_autoclose_regions( + &self.selections.disjoint_anchors_arc(), + &multibuffer_snapshot, + ); + + let show_new_completions_on_confirm = completion + .confirm + .as_ref() + .is_some_and(|confirm| confirm(intent, window, cx)); + if show_new_completions_on_confirm { + self.open_or_update_completions_menu(None, None, false, window, cx); + } + + let provider = self.completion_provider.as_ref()?; + + let lsp_store = self.project().map(|project| project.read(cx).lsp_store()); + let command = lsp_store.as_ref().and_then(|lsp_store| { + let CompletionSource::Lsp { + lsp_completion, + server_id, + .. + } = &completion.source + else { + return None; + }; + let lsp_command = lsp_completion.command.as_ref()?; + let available_commands = lsp_store + .read(cx) + .lsp_server_capabilities + .get(server_id) + .and_then(|server_capabilities| { + server_capabilities + .execute_command_provider + .as_ref() + .map(|options| options.commands.as_slice()) + })?; + if available_commands.contains(&lsp_command.command) { + Some(CodeAction { + server_id: *server_id, + range: language::Anchor::min_min_range_for_buffer(buffer.remote_id()), + lsp_action: LspAction::Command(lsp_command.clone()), + resolved: false, + }) + } else { + None + } + }); + + drop(completion); + let apply_edits = provider.apply_additional_edits_for_completion( + buffer_handle.clone(), + completions_menu.completions.clone(), + candidate_id, + true, + all_commit_ranges, + cx, + ); + + let editor_settings = EditorSettings::get_global(cx); + if editor_settings.show_signature_help_after_edits || editor_settings.auto_signature_help { + // After the code completion is finished, users often want to know what signatures are needed. + // so we should automatically call signature_help + self.show_signature_help(&ShowSignatureHelp, window, cx); + } + + Some(cx.spawn_in(window, async move |editor, cx| { + let additional_edits_tx = apply_edits.await?; + + if let Some((lsp_store, command)) = lsp_store.zip(command) { + let title = command.lsp_action.title().to_owned(); + let project_transaction = lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.apply_code_action(buffer_handle, command, false, cx) + }) + .await + .context("applying post-completion command")?; + if let Some(workspace) = editor.read_with(cx, |editor, _| editor.workspace())? { + Self::open_project_transaction( + &editor, + workspace.downgrade(), + project_transaction, + title, + cx, + ) + .await?; + } + } + + if let Some(tx_id) = tx_id + && let Some(additional_edits_tx) = additional_edits_tx + { + editor + .update(cx, |editor, cx| { + editor.buffer.update(cx, |buffer, cx| { + buffer.merge_transactions(additional_edits_tx.id, tx_id, cx) + }); + }) + .context("merge transactions")?; + } + + Ok(()) + })) + } +} + +#[cfg(any(test, feature = "test-support"))] +impl Editor { + pub fn completion_provider(&self) -> Option> { + self.completion_provider.clone() + } + + pub fn current_completions(&self) -> Option> { + let menu = self.context_menu.borrow(); + if let CodeContextMenu::Completions(menu) = menu.as_ref()? { + let completions = menu.completions.borrow(); + Some(completions.to_vec()) + } else { + None + } + } + + #[cfg(test)] + pub(super) fn disable_word_completions(&mut self) { + self.word_completions_enabled = false; + } +} + +pub trait CompletionProvider { + fn completions( + &self, + buffer: &Entity, + buffer_position: text::Anchor, + trigger: CompletionContext, + window: &mut Window, + cx: &mut Context, + ) -> Task>>; + + fn resolve_completions( + &self, + _buffer: Entity, + _completion_indices: Vec, + _completions: Rc>>, + _cx: &mut Context, + ) -> Task> { + Task::ready(Ok(false)) + } + + fn apply_additional_edits_for_completion( + &self, + _buffer: Entity, + _completions: Rc>>, + _completion_index: usize, + _push_to_history: bool, + _all_commit_ranges: Vec>, + _cx: &mut Context, + ) -> Task>> { + Task::ready(Ok(None)) + } + + fn is_completion_trigger( + &self, + buffer: &Entity, + position: language::Anchor, + text: &str, + trigger_in_words: bool, + cx: &mut Context, + ) -> bool; + + fn selection_changed(&self, _mat: Option<&StringMatch>, _window: &mut Window, _cx: &mut App) {} + + fn sort_completions(&self) -> bool { + true + } + + fn filter_completions(&self) -> bool { + true + } + + fn show_snippets(&self) -> bool { + false + } +} + +fn has_strong_snippet_prefix_match( + project: &Project, + buffer: &Entity, + buffer_anchor: text::Anchor, + classifier: &CharClassifier, + query: &str, + cx: &App, +) -> bool { + if query.chars().take(2).count() < 2 { + return false; + } + + let query = query.to_lowercase(); + let is_word_char = |character| classifier.is_word(character); + let languages = buffer.read(cx).languages_at(buffer_anchor); + let snippet_store = project.snippets().read(cx); + + languages.iter().any(|language| { + snippet_store + .snippets_for(Some(language.lsp_id()), cx) + .iter() + .flat_map(|snippet| snippet.prefix.iter()) + .flat_map(|prefix| snippet_candidate_suffixes(prefix, &is_word_char)) + .any(|candidate| candidate.to_lowercase().starts_with(&query)) + }) +} + +fn snippet_completions( + project: &Project, + buffer: &Entity, + buffer_anchor: text::Anchor, + classifier: CharClassifier, + cx: &mut App, +) -> Task> { + let languages = buffer.read(cx).languages_at(buffer_anchor); + let snippet_store = project.snippets().read(cx); + + let scopes: Vec<_> = languages + .iter() + .filter_map(|language| { + let language_name = language.lsp_id(); + let snippets = snippet_store.snippets_for(Some(language_name), cx); + + if snippets.is_empty() { + None + } else { + Some((language.default_scope(), snippets)) + } + }) + .collect(); + + if scopes.is_empty() { + return Task::ready(Ok(CompletionResponse { + completions: vec![], + display_options: CompletionDisplayOptions::default(), + is_incomplete: false, + })); + } + + let snapshot = buffer.read(cx).text_snapshot(); + let executor = cx.background_executor().clone(); + + cx.background_spawn(async move { + let is_word_char = |c| classifier.is_word(c); + + let mut is_incomplete = false; + let mut completions: Vec = Vec::new(); + + const MAX_PREFIX_LEN: usize = 128; + let buffer_offset = text::ToOffset::to_offset(&buffer_anchor, &snapshot); + let window_start = buffer_offset.saturating_sub(MAX_PREFIX_LEN); + let window_start = snapshot.clip_offset(window_start, Bias::Left); + + let max_buffer_window: String = snapshot + .text_for_range(window_start..buffer_offset) + .collect(); + + if max_buffer_window.is_empty() { + return Ok(CompletionResponse { + completions: vec![], + display_options: CompletionDisplayOptions::default(), + is_incomplete: true, + }); + } + + for (_scope, snippets) in scopes.into_iter() { + // Sort snippets by word count to match longer snippet prefixes first. + let mut sorted_snippet_candidates = snippets + .iter() + .enumerate() + .flat_map(|(snippet_ix, snippet)| { + snippet + .prefix + .iter() + .enumerate() + .map(move |(prefix_ix, prefix)| { + let word_count = + snippet_candidate_suffixes(prefix, &is_word_char).count(); + ((snippet_ix, prefix_ix), prefix, word_count) + }) + }) + .collect_vec(); + sorted_snippet_candidates + .sort_unstable_by_key(|(_, _, word_count)| Reverse(*word_count)); + + // Each prefix may be matched multiple times; the completion menu must filter out duplicates. + + let buffer_windows = snippet_candidate_suffixes(&max_buffer_window, &is_word_char) + .take( + sorted_snippet_candidates + .first() + .map(|(_, _, word_count)| *word_count) + .unwrap_or_default(), + ) + .collect_vec(); + + const MAX_RESULTS: usize = 100; + // Each match also remembers how many characters from the buffer it consumed + let mut matches: Vec<(StringMatch, usize)> = vec![]; + + let mut snippet_list_cutoff_index = 0; + for (buffer_index, buffer_window) in buffer_windows.iter().enumerate().rev() { + let word_count = buffer_index + 1; + // Increase `snippet_list_cutoff_index` until we have all of the + // snippets with sufficiently many words. + while sorted_snippet_candidates + .get(snippet_list_cutoff_index) + .is_some_and(|(_ix, _prefix, snippet_word_count)| { + *snippet_word_count >= word_count + }) + { + snippet_list_cutoff_index += 1; + } + + // Take only the candidates with at least `word_count` many words + let snippet_candidates_at_word_len = + &sorted_snippet_candidates[..snippet_list_cutoff_index]; + + let candidates = snippet_candidates_at_word_len + .iter() + .map(|(_snippet_ix, prefix, _snippet_word_count)| prefix) + .enumerate() // index in `sorted_snippet_candidates` + // First char must match + .filter(|(_ix, prefix)| { + itertools::equal( + prefix + .chars() + .next() + .into_iter() + .flat_map(|c| c.to_lowercase()), + buffer_window + .chars() + .next() + .into_iter() + .flat_map(|c| c.to_lowercase()), + ) + }) + .map(|(ix, prefix)| StringMatchCandidate::new(ix, prefix)) + .collect::>(); + + matches.extend( + fuzzy::match_strings( + &candidates, + &buffer_window, + buffer_window.chars().any(|c| c.is_uppercase()), + true, + MAX_RESULTS - matches.len(), // always prioritize longer snippets + &Default::default(), + executor.clone(), + ) + .await + .into_iter() + .map(|string_match| (string_match, buffer_window.len())), + ); + + if matches.len() >= MAX_RESULTS { + break; + } + } + + let to_lsp = |point: &text::Anchor| { + let end = text::ToPointUtf16::to_point_utf16(point, &snapshot); + point_to_lsp(end) + }; + let lsp_end = to_lsp(&buffer_anchor); + + if matches.len() >= MAX_RESULTS { + is_incomplete = true; + } + + completions.extend(matches.iter().map(|(string_match, buffer_window_len)| { + let ((snippet_index, prefix_index), matching_prefix, _snippet_word_count) = + sorted_snippet_candidates[string_match.candidate_id]; + let snippet = &snippets[snippet_index]; + let start = buffer_offset - buffer_window_len; + let start = snapshot.anchor_before(start); + let range = start..buffer_anchor; + let lsp_start = to_lsp(&start); + let lsp_range = lsp::Range { + start: lsp_start, + end: lsp_end, + }; + Completion { + replace_range: range, + new_text: snippet.body.clone(), + source: CompletionSource::Lsp { + insert_range: None, + server_id: LanguageServerId(usize::MAX), + resolved: true, + lsp_completion: Box::new(lsp::CompletionItem { + label: matching_prefix.clone(), + kind: Some(CompletionItemKind::SNIPPET), + label_details: snippet.description.as_ref().map(|description| { + lsp::CompletionItemLabelDetails { + detail: Some(description.clone()), + description: None, + } + }), + insert_text_format: Some(InsertTextFormat::SNIPPET), + text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( + lsp::InsertReplaceEdit { + new_text: snippet.body.clone(), + insert: lsp_range, + replace: lsp_range, + }, + )), + filter_text: Some(snippet.body.clone()), + sort_text: Some(char::MAX.to_string()), + ..lsp::CompletionItem::default() + }), + lsp_defaults: None, + }, + label: CodeLabel { + text: matching_prefix.clone(), + runs: Vec::new(), + filter_range: 0..matching_prefix.len(), + }, + icon_path: None, + documentation: Some(CompletionDocumentation::SingleLineAndMultiLinePlainText { + single_line: snippet.name.clone().into(), + plain_text: snippet + .description + .clone() + .map(|description| description.into()), + }), + insert_text_mode: None, + confirm: None, + match_start: Some(start), + snippet_deduplication_key: Some((snippet_index, prefix_index)), + } + })); + } + + Ok(CompletionResponse { + completions, + display_options: CompletionDisplayOptions::default(), + is_incomplete, + }) + }) +} + +impl CompletionProvider for Entity { + fn completions( + &self, + buffer: &Entity, + buffer_position: text::Anchor, + options: CompletionContext, + _window: &mut Window, + cx: &mut Context, + ) -> Task>> { + self.update(cx, |project, cx| { + let task = project.completions(buffer, buffer_position, options, cx); + cx.background_spawn(task) + }) + } + + fn resolve_completions( + &self, + buffer: Entity, + completion_indices: Vec, + completions: Rc>>, + cx: &mut Context, + ) -> Task> { + self.update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store.resolve_completions(buffer, completion_indices, completions, cx) + }) + }) + } + + fn apply_additional_edits_for_completion( + &self, + buffer: Entity, + completions: Rc>>, + completion_index: usize, + push_to_history: bool, + all_commit_ranges: Vec>, + cx: &mut Context, + ) -> Task>> { + self.update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store.apply_additional_edits_for_completion( + buffer, + completions, + completion_index, + push_to_history, + all_commit_ranges, + cx, + ) + }) + }) + } + + fn is_completion_trigger( + &self, + buffer: &Entity, + position: language::Anchor, + text: &str, + trigger_in_words: bool, + cx: &mut Context, + ) -> bool { + let mut chars = text.chars(); + let char = if let Some(char) = chars.next() { + char + } else { + return false; + }; + if chars.next().is_some() { + return false; + } + + let buffer = buffer.read(cx); + let snapshot = buffer.snapshot(); + let classifier = snapshot + .char_classifier_at(position) + .scope_context(Some(CharScopeContext::Completion)); + if trigger_in_words && classifier.is_word(char) { + return true; + } + + buffer.completion_triggers().contains(text) + } + + fn show_snippets(&self) -> bool { + true + } +} + +pub(crate) fn split_words(text: &str) -> impl std::iter::Iterator + '_ { + let mut prev_index = 0; + let mut prev_codepoint: Option = None; + text.char_indices() + .chain([(text.len(), '\0')]) + .filter_map(move |(index, codepoint)| { + let prev_codepoint = prev_codepoint.replace(codepoint)?; + let is_boundary = index == text.len() + || !prev_codepoint.is_uppercase() && codepoint.is_uppercase() + || !prev_codepoint.is_alphanumeric() && codepoint.is_alphanumeric(); + if is_boundary { + let chunk = &text[prev_index..index]; + prev_index = index; + Some(chunk) + } else { + None + } + }) +} + +/// Given a string of text immediately before the cursor, iterates over possible +/// strings a snippet could match to. More precisely: returns an iterator over +/// suffixes of `text` created by splitting at word boundaries (before & after +/// every non-word character). +/// +/// Shorter suffixes are returned first. +pub(crate) fn snippet_candidate_suffixes<'a>( + text: &'a str, + is_word_char: &'a dyn Fn(char) -> bool, +) -> impl std::iter::Iterator + 'a { + let mut prev_index = text.len(); + let mut prev_codepoint = None; + text.char_indices() + .rev() + .chain([(0, '\0')]) + .filter_map(move |(index, codepoint)| { + let prev_index = std::mem::replace(&mut prev_index, index); + let prev_codepoint = prev_codepoint.replace(codepoint)?; + if is_word_char(prev_codepoint) && is_word_char(codepoint) { + None + } else { + let chunk = &text[prev_index..]; // go to end of string + Some(chunk) + } + }) +} diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 6dcc10b0ee2295..175b430ff014cf 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -57,11 +57,18 @@ mod signature_help; #[cfg(any(test, feature = "test-support"))] pub mod test; +mod code_actions; +mod completions; mod config; mod diagnostics; mod rewrap; pub(crate) use actions::*; +pub use code_actions::CodeActionProvider; +pub use completions::CompletionProvider; +#[cfg(test)] +pub(crate) use completions::snippet_candidate_suffixes; +pub(crate) use completions::split_words; use diagnostics::{ActiveDiagnostic, GlobalDiagnosticRenderer, InlineDiagnostic}; pub use diagnostics::{DiagnosticRenderer, set_diagnostic_renderer}; pub use display_map::{ @@ -3362,15 +3369,6 @@ impl Editor { self.custom_context_menu = Some(Box::new(f)) } - pub fn set_completion_provider(&mut self, provider: Option>) { - self.completion_provider = provider; - } - - #[cfg(any(test, feature = "test-support"))] - pub fn completion_provider(&self) -> Option> { - self.completion_provider.clone() - } - pub fn semantics_provider(&self) -> Option> { self.semantics_provider.clone() } @@ -3578,10 +3576,6 @@ impl Editor { } } - pub fn set_show_completions_on_input(&mut self, show_completions_on_input: Option) { - self.show_completions_on_input_override = show_completions_on_input; - } - pub fn set_show_edit_predictions( &mut self, show_edit_predictions: Option, @@ -5748,44 +5742,6 @@ impl Editor { Some(()) } - fn trigger_completion_on_input( - &mut self, - text: &str, - trigger_in_words: bool, - window: &mut Window, - cx: &mut Context, - ) { - let completions_source = self - .context_menu - .borrow() - .as_ref() - .and_then(|menu| match menu { - CodeContextMenu::Completions(completions_menu) => Some(completions_menu.source), - CodeContextMenu::CodeActions(_) => None, - }); - - match completions_source { - Some(CompletionsMenuSource::Words { .. }) => { - self.open_or_update_completions_menu( - Some(CompletionsMenuSource::Words { - ignore_threshold: false, - }), - None, - trigger_in_words, - window, - cx, - ); - } - _ => self.open_or_update_completions_menu( - None, - Some(text.to_owned()).filter(|x| !x.is_empty()), - trigger_in_words, - window, - cx, - ), - } - } - /// If any empty selections is touching the start of its innermost containing autoclose /// region, expand it to select the brackets. fn select_autoclose_pair(&mut self, window: &mut Window, cx: &mut Context) { @@ -5917,1371 +5873,98 @@ impl Editor { }); } - fn completion_query(buffer: &MultiBufferSnapshot, position: impl ToOffset) -> Option { - let offset = position.to_offset(buffer); - let (word_range, kind) = - buffer.surrounding_word(offset, Some(CharScopeContext::Completion)); - if offset > word_range.start && kind == Some(CharKind::Word) { - Some( - buffer - .text_for_range(word_range.start..offset) - .collect::(), - ) - } else { - None - } - } - - pub fn is_lsp_relevant(&self, file: Option<&Arc>, cx: &App) -> bool { - let Some(project) = self.project() else { - return false; - }; - let Some(buffer_file) = project::File::from_dyn(file) else { - return false; - }; - let Some(entry_id) = buffer_file.project_entry_id() else { - return false; - }; - let project = project.read(cx); - let Some(buffer_worktree) = project.worktree_for_id(buffer_file.worktree_id(cx), cx) else { - return false; - }; - let Some(worktree_entry) = buffer_worktree.read(cx).entry_for_id(entry_id) else { - return false; - }; - !worktree_entry.is_ignored - } - - pub fn visible_buffers(&self, cx: &mut Context) -> Vec> { - let display_snapshot = self.display_snapshot(cx); - let visible_range = self.multi_buffer_visible_range(&display_snapshot, cx); - let multi_buffer = self.buffer().read(cx); - display_snapshot - .buffer_snapshot() - .range_to_buffer_ranges(visible_range) - .into_iter() - .filter(|(_, excerpt_visible_range, _)| !excerpt_visible_range.is_empty()) - .filter_map(|(buffer_snapshot, _, _)| multi_buffer.buffer(buffer_snapshot.remote_id())) - .collect() - } - - pub fn visible_buffer_ranges( - &self, - cx: &mut Context, - ) -> Vec<( - BufferSnapshot, - Range, - ExcerptRange, - )> { - let display_snapshot = self.display_snapshot(cx); - let visible_range = self.multi_buffer_visible_range(&display_snapshot, cx); - display_snapshot - .buffer_snapshot() - .range_to_buffer_ranges(visible_range) - .into_iter() - .filter(|(_, excerpt_visible_range, _)| !excerpt_visible_range.is_empty()) - .collect() - } - - pub fn text_layout_details(&self, window: &mut Window, cx: &mut App) -> TextLayoutDetails { - TextLayoutDetails { - text_system: window.text_system().clone(), - editor_style: self.style.clone().unwrap_or_else(|| self.create_style(cx)), - rem_size: window.rem_size(), - scroll_anchor: self.scroll_manager.shared_scroll_anchor(cx), - visible_rows: self.visible_line_count(), - vertical_scroll_margin: self.scroll_manager.vertical_scroll_margin, - } - } - - fn trigger_on_type_formatting( - &self, - input: String, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - if input.chars().count() != 1 { - return None; - } - - let project = self.project()?; - let position = self.selections.newest_anchor().head(); - let (buffer, buffer_position) = self - .buffer - .read(cx) - .text_anchor_for_position(position, cx)?; - - let settings = LanguageSettings::for_buffer_at(&buffer.read(cx), buffer_position, cx); - if !settings.use_on_type_format { - return None; - } - - // OnTypeFormatting returns a list of edits, no need to pass them between Zed instances, - // hence we do LSP request & edit on host side only — add formats to host's history. - let push_to_lsp_host_history = true; - // If this is not the host, append its history with new edits. - let push_to_client_history = project.read(cx).is_via_collab(); - - let on_type_formatting = project.update(cx, |project, cx| { - project.on_type_format( - buffer.clone(), - buffer_position, - input, - push_to_lsp_host_history, - cx, - ) - }); - Some(cx.spawn_in(window, async move |editor, cx| { - if let Some(transaction) = on_type_formatting.await? { - if push_to_client_history { - buffer.update(cx, |buffer, _| { - buffer.push_transaction(transaction, Instant::now()); - buffer.finalize_last_transaction(); - }); - } - editor.update(cx, |editor, cx| { - editor.refresh_document_highlights(cx); - })?; - } - Ok(()) - })) - } - - pub fn show_word_completions( - &mut self, - _: &ShowWordCompletions, - window: &mut Window, - cx: &mut Context, - ) { - self.open_or_update_completions_menu( - Some(CompletionsMenuSource::Words { - ignore_threshold: true, - }), - None, - false, - window, - cx, - ); - } - - pub fn show_completions( - &mut self, - _: &ShowCompletions, - window: &mut Window, - cx: &mut Context, - ) { - self.open_or_update_completions_menu(None, None, false, window, cx); - } - - fn open_or_update_completions_menu( - &mut self, - requested_source: Option, - trigger: Option, - trigger_in_words: bool, + fn open_transaction_for_hidden_buffers( + workspace: Entity, + transaction: ProjectTransaction, + title: String, window: &mut Window, cx: &mut Context, ) { - if self.pending_rename.is_some() { + if transaction.0.is_empty() { return; } - let completions_source = self - .context_menu - .borrow() - .as_ref() - .and_then(|menu| match menu { - CodeContextMenu::Completions(completions_menu) => Some(completions_menu.source), - CodeContextMenu::CodeActions(_) => None, - }); - - let multibuffer_snapshot = self.buffer.read(cx).read(cx); - - // Typically `start` == `end`, but with snippet tabstop choices the default choice is - // inserted and selected. To handle that case, the start of the selection is used so that - // the menu starts with all choices. - let position = self - .selections - .newest_anchor() - .start - .bias_right(&multibuffer_snapshot); + let edited_buffers_already_open = { + let other_editors: Vec> = workspace + .read(cx) + .panes() + .iter() + .flat_map(|pane| pane.read(cx).items_of_type::()) + .filter(|editor| editor.entity_id() != cx.entity_id()) + .collect(); - if position.diff_base_anchor().is_some() { - return; - } - let multibuffer_position = multibuffer_snapshot.anchor_before(position); - let Some((buffer_position, _)) = - multibuffer_snapshot.anchor_to_buffer_anchor(multibuffer_position) - else { - return; - }; - let Some(buffer) = self.buffer.read(cx).buffer(buffer_position.buffer_id) else { - return; + transaction.0.keys().all(|buffer| { + other_editors.iter().any(|editor| { + let multi_buffer = editor.read(cx).buffer(); + multi_buffer.read(cx).is_singleton() + && multi_buffer + .read(cx) + .as_singleton() + .map_or(false, |singleton| { + singleton.entity_id() == buffer.entity_id() + }) + }) + }) }; - let buffer_snapshot = buffer.read(cx).snapshot(); - - let menu_is_open = matches!( - self.context_menu.borrow().as_ref(), - Some(CodeContextMenu::Completions(_)) - ); - - let language = buffer_snapshot - .language_at(buffer_position) - .map(|language| language.name()); - let language_settings = multibuffer_snapshot.language_settings_at(multibuffer_position, cx); - let completion_settings = language_settings.completions.clone(); - - let show_completions_on_input = self - .show_completions_on_input_override - .unwrap_or(language_settings.show_completions_on_input); - if !menu_is_open && trigger.is_some() && !show_completions_on_input { - return; + if !edited_buffers_already_open { + let workspace = workspace.downgrade(); + cx.defer_in(window, move |_, window, cx| { + cx.spawn_in(window, async move |editor, cx| { + Self::open_project_transaction(&editor, workspace, transaction, title, cx) + .await + .ok() + }) + .detach(); + }); } + } - let query: Option> = - Self::completion_query(&multibuffer_snapshot, multibuffer_position) - .map(|query| query.into()); - - drop(multibuffer_snapshot); - - // Hide the current completions menu when query is empty. Without this, cached - // completions from before the trigger char may be reused (#32774). - if query.is_none() && menu_is_open { - self.hide_context_menu(window, cx); + pub async fn open_project_transaction( + editor: &WeakEntity, + workspace: WeakEntity, + transaction: ProjectTransaction, + title: String, + cx: &mut AsyncWindowContext, + ) -> Result<()> { + let mut entries = transaction.0.into_iter().collect::>(); + cx.update(|_, cx| { + entries.sort_unstable_by_key(|(buffer, _)| { + buffer.read(cx).file().map(|f| f.path().clone()) + }); + })?; + if entries.is_empty() { + return Ok(()); } - let mut ignore_word_threshold = false; - let provider = match requested_source { - Some(CompletionsMenuSource::Normal) | None => self.completion_provider.clone(), - Some(CompletionsMenuSource::Words { ignore_threshold }) => { - ignore_word_threshold = ignore_threshold; - None - } - Some(CompletionsMenuSource::SnippetChoices) - | Some(CompletionsMenuSource::SnippetsOnly) => { - log::error!("bug: SnippetChoices requested_source is not handled"); - None - } - }; - - let sort_completions = provider - .as_ref() - .is_some_and(|provider| provider.sort_completions()); + // If the project transaction's edits are all contained within this editor, then + // avoid opening a new editor to display them. - let filter_completions = provider - .as_ref() - .is_none_or(|provider| provider.filter_completions()); + if let [(buffer, transaction)] = &*entries { + let cursor_excerpt = editor.update(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let head = editor.selections.newest_anchor().head(); + let (buffer_snapshot, excerpt_range) = snapshot.excerpt_containing(head..head)?; + if buffer_snapshot.remote_id() != buffer.read(cx).remote_id() { + return None; + } + Some(excerpt_range) + })?; - let was_snippets_only = matches!( - completions_source, - Some(CompletionsMenuSource::SnippetsOnly) - ); + if let Some(excerpt_range) = cursor_excerpt { + let all_edits_within_excerpt = buffer.read_with(cx, |buffer, _| { + let excerpt_range = excerpt_range.context.to_offset(buffer); + buffer + .edited_ranges_for_transaction::(transaction) + .all(|range| { + excerpt_range.start <= range.start && excerpt_range.end >= range.end + }) + }); - if let Some(CodeContextMenu::Completions(menu)) = self.context_menu.borrow_mut().as_mut() { - if filter_completions { - menu.filter( - query.clone().unwrap_or_default(), - buffer_position, - &buffer, - provider.clone(), - window, - cx, - ); - } - // When `is_incomplete` is false, no need to re-query completions when the current query - // is a suffix of the initial query. - let was_complete = !menu.is_incomplete; - if was_complete && !was_snippets_only { - // If the new query is a suffix of the old query (typing more characters) and - // the previous result was complete, the existing completions can be filtered. - // - // Note that snippet completions are always complete. - let query_matches = match (&menu.initial_query, &query) { - (Some(initial_query), Some(query)) => query.starts_with(initial_query.as_ref()), - (None, _) => true, - _ => false, - }; - if query_matches { - let position_matches = if menu.initial_position == position { - true - } else { - let snapshot = self.buffer.read(cx).read(cx); - menu.initial_position.to_offset(&snapshot) == position.to_offset(&snapshot) - }; - if position_matches { - return; - } + if all_edits_within_excerpt { + return Ok(()); } } - }; - - let (word_replace_range, word_to_exclude) = if let (word_range, Some(CharKind::Word)) = - buffer_snapshot.surrounding_word(buffer_position, None) - { - let word_to_exclude = buffer_snapshot - .text_for_range(word_range.clone()) - .collect::(); - ( - buffer_snapshot.anchor_before(word_range.start) - ..buffer_snapshot.anchor_after(buffer_position), - Some(word_to_exclude), - ) - } else { - (buffer_position..buffer_position, None) - }; - - let show_completion_documentation = buffer_snapshot - .settings_at(buffer_position, cx) - .show_completion_documentation; - - // The document can be large, so stay in reasonable bounds when searching for words, - // otherwise completion pop-up might be slow to appear. - const WORD_LOOKUP_ROWS: u32 = 5_000; - let buffer_row = text::ToPoint::to_point(&buffer_position, &buffer_snapshot).row; - let min_word_search = buffer_snapshot.clip_point( - Point::new(buffer_row.saturating_sub(WORD_LOOKUP_ROWS), 0), - Bias::Left, - ); - let max_word_search = buffer_snapshot.clip_point( - Point::new(buffer_row + WORD_LOOKUP_ROWS, 0).min(buffer_snapshot.max_point()), - Bias::Right, - ); - let word_search_range = buffer_snapshot.point_to_offset(min_word_search) - ..buffer_snapshot.point_to_offset(max_word_search); - - let skip_digits = query - .as_ref() - .is_none_or(|query| !query.chars().any(|c| c.is_digit(10))); - - let load_provider_completions = provider.as_ref().is_some_and(|provider| { - trigger.as_ref().is_none_or(|trigger| { - provider.is_completion_trigger( - &buffer, - buffer_position, - trigger, - trigger_in_words, - cx, - ) - }) - }); - - let provider_responses = if let Some(provider) = &provider - && load_provider_completions - { - let trigger_character = trigger - .as_ref() - .filter(|trigger| { - buffer - .read(cx) - .completion_triggers() - .contains(trigger.as_str()) - }) - .cloned(); - let completion_context = CompletionContext { - trigger_kind: match &trigger_character { - Some(_) => CompletionTriggerKind::TRIGGER_CHARACTER, - None => CompletionTriggerKind::INVOKED, - }, - trigger_character, - }; - - provider.completions(&buffer, buffer_position, completion_context, window, cx) - } else { - Task::ready(Ok(Vec::new())) - }; - - let load_word_completions = if !self.word_completions_enabled { - false - } else if requested_source - == Some(CompletionsMenuSource::Words { - ignore_threshold: true, - }) - { - true - } else { - load_provider_completions - && completion_settings.words != WordsCompletionMode::Disabled - && (ignore_word_threshold || { - let words_min_length = completion_settings.words_min_length; - // check whether word has at least `words_min_length` characters - let query_chars = query.iter().flat_map(|q| q.chars()); - query_chars.take(words_min_length).count() == words_min_length - }) - }; - - let mut words = if load_word_completions { - cx.background_spawn({ - let buffer_snapshot = buffer_snapshot.clone(); - async move { - buffer_snapshot.words_in_range(WordsQuery { - fuzzy_contents: None, - range: word_search_range, - skip_digits, - }) - } - }) - } else { - Task::ready(BTreeMap::default()) - }; - - let snippet_char_classifier = buffer_snapshot - .char_classifier_at(buffer_position) - .scope_context(Some(CharScopeContext::Completion)); - - let snippets = if let Some(provider) = &provider - && provider.show_snippets() - && let Some(project) = self.project() - { - let word_trigger = trigger.as_ref().is_some_and(|trigger| { - !trigger.is_empty() - && trigger - .chars() - .all(|character| snippet_char_classifier.is_word(character)) - }); - let requires_strong_snippet_match = !menu_is_open && !trigger_in_words && word_trigger; - let load_snippet_completions = !requires_strong_snippet_match - || query.as_ref().is_some_and(|query| { - let project = project.read(cx); - has_strong_snippet_prefix_match( - &project, - &buffer, - buffer_position, - &snippet_char_classifier, - query, - cx, - ) - }); - - if load_snippet_completions { - project.update(cx, |project, cx| { - snippet_completions( - project, - &buffer, - buffer_position, - snippet_char_classifier, - cx, - ) - }) - } else { - Task::ready(Ok(CompletionResponse { - completions: Vec::new(), - display_options: Default::default(), - is_incomplete: false, - })) - } - } else { - Task::ready(Ok(CompletionResponse { - completions: Vec::new(), - display_options: Default::default(), - is_incomplete: false, - })) - }; - - let snippet_sort_order = EditorSettings::get_global(cx).snippet_sort_order; - - let id = post_inc(&mut self.next_completion_id); - let task = cx.spawn_in(window, async move |editor, cx| { - let Ok(()) = editor.update(cx, |this, _| { - this.completion_tasks.retain(|(task_id, _)| *task_id >= id); - }) else { - return; - }; - - // TODO: Ideally completions from different sources would be selectively re-queried, so - // that having one source with `is_incomplete: true` doesn't cause all to be re-queried. - let mut completions = Vec::new(); - let mut is_incomplete = false; - let mut display_options: Option = None; - if let Some(provider_responses) = provider_responses.await.log_err() - && !provider_responses.is_empty() - { - for response in provider_responses { - completions.extend(response.completions); - is_incomplete = is_incomplete || response.is_incomplete; - match display_options.as_mut() { - None => { - display_options = Some(response.display_options); - } - Some(options) => options.merge(&response.display_options), - } - } - if completion_settings.words == WordsCompletionMode::Fallback { - words = Task::ready(BTreeMap::default()); - } - } - let display_options = display_options.unwrap_or_default(); - - let mut words = words.await; - if let Some(word_to_exclude) = &word_to_exclude { - words.remove(word_to_exclude); - } - for lsp_completion in &completions { - words.remove(&lsp_completion.new_text); - } - completions.extend(words.into_iter().map(|(word, word_range)| Completion { - replace_range: word_replace_range.clone(), - new_text: word.clone(), - label: CodeLabel::plain(word, None), - match_start: None, - snippet_deduplication_key: None, - icon_path: None, - documentation: None, - source: CompletionSource::BufferWord { - word_range, - resolved: false, - }, - insert_text_mode: Some(InsertTextMode::AS_IS), - confirm: None, - })); - - completions.extend( - snippets - .await - .into_iter() - .flat_map(|response| response.completions), - ); - - let menu = if completions.is_empty() { - None - } else { - let Ok((mut menu, matches_task)) = editor.update(cx, |editor, cx| { - let languages = editor - .workspace - .as_ref() - .and_then(|(workspace, _)| workspace.upgrade()) - .map(|workspace| workspace.read(cx).app_state().languages.clone()); - let menu = CompletionsMenu::new( - id, - requested_source.unwrap_or(if load_provider_completions { - CompletionsMenuSource::Normal - } else { - CompletionsMenuSource::SnippetsOnly - }), - sort_completions, - show_completion_documentation, - position, - query.clone(), - is_incomplete, - buffer.clone(), - completions.into(), - editor - .context_menu() - .borrow_mut() - .as_ref() - .map(|menu| menu.primary_scroll_handle()), - display_options, - snippet_sort_order, - languages, - language, - cx, - ); - - let query = if filter_completions { query } else { None }; - let matches_task = menu.do_async_filtering( - query.unwrap_or_default(), - buffer_position, - &buffer, - cx, - ); - (menu, matches_task) - }) else { - return; - }; - - let matches = matches_task.await; - - let Ok(()) = editor.update_in(cx, |editor, window, cx| { - // Newer menu already set, so exit. - if let Some(CodeContextMenu::Completions(prev_menu)) = - editor.context_menu.borrow().as_ref() - && prev_menu.id > id - { - return; - }; - - // Only valid to take prev_menu because either the new menu is immediately set - // below, or the menu is hidden. - if let Some(CodeContextMenu::Completions(prev_menu)) = - editor.context_menu.borrow_mut().take() - { - let position_matches = - if prev_menu.initial_position == menu.initial_position { - true - } else { - let snapshot = editor.buffer.read(cx).read(cx); - prev_menu.initial_position.to_offset(&snapshot) - == menu.initial_position.to_offset(&snapshot) - }; - if position_matches { - // Preserve markdown cache before `set_filter_results` because it will - // try to populate the documentation cache. - menu.preserve_markdown_cache(prev_menu); - } - }; - - menu.set_filter_results(matches, provider, window, cx); - }) else { - return; - }; - - menu.visible().then_some(menu) - }; - - editor - .update_in(cx, |editor, window, cx| { - if editor.focus_handle.is_focused(window) - && let Some(menu) = menu - { - *editor.context_menu.borrow_mut() = - Some(CodeContextMenu::Completions(menu)); - - crate::hover_popover::hide_hover(editor, cx); - if editor.show_edit_predictions_in_menu() { - editor.update_visible_edit_prediction(window, cx); - } else { - editor - .discard_edit_prediction(EditPredictionDiscardReason::Ignored, cx); - } - - cx.notify(); - return; - } - - if editor.completion_tasks.len() <= 1 { - // If there are no more completion tasks and the last menu was empty, we should hide it. - let was_hidden = editor.hide_context_menu(window, cx).is_none(); - // If it was already hidden and we don't show edit predictions in the menu, - // we should also show the edit prediction when available. - if was_hidden && editor.show_edit_predictions_in_menu() { - editor.update_visible_edit_prediction(window, cx); - } - } - }) - .ok(); - }); - - self.completion_tasks.push((id, task)); - } - - #[cfg(any(test, feature = "test-support"))] - pub fn current_completions(&self) -> Option> { - let menu = self.context_menu.borrow(); - if let CodeContextMenu::Completions(menu) = menu.as_ref()? { - let completions = menu.completions.borrow(); - Some(completions.to_vec()) - } else { - None - } - } - - pub fn with_completions_menu_matching_id( - &self, - id: CompletionId, - f: impl FnOnce(Option<&mut CompletionsMenu>) -> R, - ) -> R { - let mut context_menu = self.context_menu.borrow_mut(); - let Some(CodeContextMenu::Completions(completions_menu)) = &mut *context_menu else { - return f(None); - }; - if completions_menu.id != id { - return f(None); - } - f(Some(completions_menu)) - } - - pub fn confirm_completion( - &mut self, - action: &ConfirmCompletion, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - if self.read_only(cx) { - return None; - } - self.do_completion(action.item_ix, CompletionIntent::Complete, window, cx) - } - - pub fn confirm_completion_insert( - &mut self, - _: &ConfirmCompletionInsert, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - if self.read_only(cx) { - return None; - } - self.do_completion(None, CompletionIntent::CompleteWithInsert, window, cx) - } - - pub fn confirm_completion_replace( - &mut self, - _: &ConfirmCompletionReplace, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - if self.read_only(cx) { - return None; - } - self.do_completion(None, CompletionIntent::CompleteWithReplace, window, cx) - } - - pub fn compose_completion( - &mut self, - action: &ComposeCompletion, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - self.do_completion(action.item_ix, CompletionIntent::Compose, window, cx) - } - - fn do_completion( - &mut self, - item_ix: Option, - intent: CompletionIntent, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - use language::ToOffset as _; - - let CodeContextMenu::Completions(completions_menu) = self.hide_context_menu(window, cx)? - else { - return None; - }; - - let candidate_id = { - let entries = completions_menu.entries.borrow(); - let mat = entries.get(item_ix.unwrap_or(completions_menu.selected_item))?; - if self.show_edit_predictions_in_menu() { - self.discard_edit_prediction(EditPredictionDiscardReason::Rejected, cx); - } - mat.candidate_id - }; - - let completion = completions_menu - .completions - .borrow() - .get(candidate_id)? - .clone(); - cx.stop_propagation(); - - let buffer_handle = completions_menu.buffer.clone(); - let multibuffer_snapshot = self.buffer.read(cx).snapshot(cx); - let (initial_position, _) = - multibuffer_snapshot.anchor_to_buffer_anchor(completions_menu.initial_position)?; - - let CompletionEdit { - new_text, - snippet, - replace_range, - } = process_completion_for_edit(&completion, intent, &buffer_handle, &initial_position, cx); - - let buffer = buffer_handle.read(cx).snapshot(); - let newest_selection = self.selections.newest_anchor(); - - let Some(replace_range_multibuffer) = - multibuffer_snapshot.buffer_anchor_range_to_anchor_range(replace_range.clone()) - else { - return None; - }; - - let Some((buffer_snapshot, newest_range_buffer)) = - multibuffer_snapshot.anchor_range_to_buffer_anchor_range(newest_selection.range()) - else { - return None; - }; - - let old_text = buffer - .text_for_range(replace_range.clone()) - .collect::(); - let lookbehind = newest_range_buffer - .start - .to_offset(buffer_snapshot) - .saturating_sub(replace_range.start.to_offset(&buffer_snapshot)); - let lookahead = replace_range - .end - .to_offset(&buffer_snapshot) - .saturating_sub(newest_range_buffer.end.to_offset(&buffer)); - let prefix = &old_text[..old_text.len().saturating_sub(lookahead)]; - let suffix = &old_text[lookbehind.min(old_text.len())..]; - - let selections = self - .selections - .all::(&self.display_snapshot(cx)); - let mut ranges = Vec::new(); - let mut all_commit_ranges = Vec::new(); - let mut linked_edits = LinkedEdits::new(); - - let text: Arc = new_text.clone().into(); - for selection in &selections { - let range = if selection.id == newest_selection.id { - replace_range_multibuffer.clone() - } else { - let mut range = selection.range(); - - // if prefix is present, don't duplicate it - if multibuffer_snapshot - .contains_str_at(range.start.saturating_sub_usize(lookbehind), prefix) - { - range.start = range.start.saturating_sub_usize(lookbehind); - - // if suffix is also present, mimic the newest cursor and replace it - if selection.id != newest_selection.id - && multibuffer_snapshot.contains_str_at(range.end, suffix) - { - range.end += lookahead; - } - } - range.to_anchors(&multibuffer_snapshot) - }; - - ranges.push(range.clone()); - - let start_anchor = multibuffer_snapshot.anchor_before(range.start); - let end_anchor = multibuffer_snapshot.anchor_after(range.end); - - if let Some((buffer_snapshot_2, anchor_range)) = - multibuffer_snapshot.anchor_range_to_buffer_anchor_range(start_anchor..end_anchor) - && buffer_snapshot_2.remote_id() == buffer_snapshot.remote_id() - { - all_commit_ranges.push(anchor_range.clone()); - if !self.linked_edit_ranges.is_empty() { - linked_edits.push(&self, anchor_range, text.clone(), cx); - } - } - } - - let common_prefix_len = old_text - .chars() - .zip(new_text.chars()) - .take_while(|(a, b)| a == b) - .map(|(a, _)| a.len_utf8()) - .sum::(); - - cx.emit(EditorEvent::InputHandled { - utf16_range_to_replace: None, - text: new_text[common_prefix_len..].into(), - }); - - let tx_id = self.transact(window, cx, |editor, window, cx| { - if let Some(mut snippet) = snippet { - snippet.text = new_text.to_string(); - let offset_ranges = ranges - .iter() - .map(|range| range.to_offset(&multibuffer_snapshot)) - .collect::>(); - editor - .insert_snippet(&offset_ranges, snippet, window, cx) - .log_err(); - } else { - editor.buffer.update(cx, |multi_buffer, cx| { - let auto_indent = match completion.insert_text_mode { - Some(InsertTextMode::AS_IS) => None, - _ => editor.autoindent_mode.clone(), - }; - let edits = ranges.into_iter().map(|range| (range, new_text.as_str())); - multi_buffer.edit(edits, auto_indent, cx); - }); - } - linked_edits.apply(cx); - editor.refresh_edit_prediction(true, false, window, cx); - }); - self.invalidate_autoclose_regions( - &self.selections.disjoint_anchors_arc(), - &multibuffer_snapshot, - ); - - let show_new_completions_on_confirm = completion - .confirm - .as_ref() - .is_some_and(|confirm| confirm(intent, window, cx)); - if show_new_completions_on_confirm { - self.open_or_update_completions_menu(None, None, false, window, cx); - } - - let provider = self.completion_provider.as_ref()?; - - let lsp_store = self.project().map(|project| project.read(cx).lsp_store()); - let command = lsp_store.as_ref().and_then(|lsp_store| { - let CompletionSource::Lsp { - lsp_completion, - server_id, - .. - } = &completion.source - else { - return None; - }; - let lsp_command = lsp_completion.command.as_ref()?; - let available_commands = lsp_store - .read(cx) - .lsp_server_capabilities - .get(server_id) - .and_then(|server_capabilities| { - server_capabilities - .execute_command_provider - .as_ref() - .map(|options| options.commands.as_slice()) - })?; - if available_commands.contains(&lsp_command.command) { - Some(CodeAction { - server_id: *server_id, - range: language::Anchor::min_min_range_for_buffer(buffer.remote_id()), - lsp_action: LspAction::Command(lsp_command.clone()), - resolved: false, - }) - } else { - None - } - }); - - drop(completion); - let apply_edits = provider.apply_additional_edits_for_completion( - buffer_handle.clone(), - completions_menu.completions.clone(), - candidate_id, - true, - all_commit_ranges, - cx, - ); - - let editor_settings = EditorSettings::get_global(cx); - if editor_settings.show_signature_help_after_edits || editor_settings.auto_signature_help { - // After the code completion is finished, users often want to know what signatures are needed. - // so we should automatically call signature_help - self.show_signature_help(&ShowSignatureHelp, window, cx); - } - - Some(cx.spawn_in(window, async move |editor, cx| { - let additional_edits_tx = apply_edits.await?; - - if let Some((lsp_store, command)) = lsp_store.zip(command) { - let title = command.lsp_action.title().to_owned(); - let project_transaction = lsp_store - .update(cx, |lsp_store, cx| { - lsp_store.apply_code_action(buffer_handle, command, false, cx) - }) - .await - .context("applying post-completion command")?; - if let Some(workspace) = editor.read_with(cx, |editor, _| editor.workspace())? { - Self::open_project_transaction( - &editor, - workspace.downgrade(), - project_transaction, - title, - cx, - ) - .await?; - } - } - - if let Some(tx_id) = tx_id - && let Some(additional_edits_tx) = additional_edits_tx - { - editor - .update(cx, |editor, cx| { - editor.buffer.update(cx, |buffer, cx| { - buffer.merge_transactions(additional_edits_tx.id, tx_id, cx) - }); - }) - .context("merge transactions")?; - } - - Ok(()) - })) - } - - /// Toggles an action selection menu for the latest selection. - /// May show LSP code actions, code lens' command, runnables and potentially more entities applicable as actions. - /// Previous menu toggled with this method will be closed. - pub fn toggle_code_actions( - &mut self, - action: &ToggleCodeActions, - window: &mut Window, - cx: &mut Context, - ) { - let quick_launch = action.quick_launch; - let mut context_menu = self.context_menu.borrow_mut(); - if let Some(CodeContextMenu::CodeActions(code_actions)) = context_menu.as_ref() { - if code_actions.deployed_from == action.deployed_from { - // Toggle if we're selecting the same one - *context_menu = None; - cx.notify(); - return; - } else { - // Otherwise, clear it and start a new one - *context_menu = None; - cx.notify(); - } - } - drop(context_menu); - let snapshot = self.snapshot(window, cx); - let deployed_from = action.deployed_from.clone(); - let action = action.clone(); - self.completion_tasks.clear(); - self.discard_edit_prediction(EditPredictionDiscardReason::Ignored, cx); - - let multibuffer_point = match &action.deployed_from { - Some(CodeActionSource::Indicator(row)) | Some(CodeActionSource::RunMenu(row)) => { - DisplayPoint::new(*row, 0).to_point(&snapshot) - } - _ => self - .selections - .newest::(&snapshot.display_snapshot) - .head(), - }; - let Some((buffer, buffer_row)) = snapshot - .buffer_snapshot() - .buffer_line_for_row(MultiBufferRow(multibuffer_point.row)) - .and_then(|(buffer_snapshot, range)| { - self.buffer() - .read(cx) - .buffer(buffer_snapshot.remote_id()) - .map(|buffer| (buffer, range.start.row)) - }) - else { - return; - }; - let buffer_id = buffer.read(cx).remote_id(); - let tasks = self - .runnables - .runnables((buffer_id, buffer_row)) - .map(|t| Arc::new(t.to_owned())); - - let project = self.project.clone(); - let runnable_task = match deployed_from { - Some(CodeActionSource::Indicator(_)) => Task::ready(Ok(Default::default())), - _ => { - let mut task_context_task = Task::ready(Ok(None)); - let workspace = self.workspace().map(|w| w.downgrade()); - if let Some(tasks) = &tasks - && let Some(project) = project - { - task_context_task = - Self::build_tasks_context(&project, &buffer, buffer_row, tasks, cx); - } - - cx.spawn_in(window, { - let buffer = buffer.clone(); - async move |editor, cx| { - let task_context = match workspace { - Some(ws) => task_context_task - .await - .notify_workspace_async_err(ws, cx) - .flatten(), - None => task_context_task.await.ok().flatten(), - }; - - let resolved_tasks = - tasks - .zip(task_context.clone()) - .map(|(tasks, task_context)| ResolvedTasks { - templates: tasks.resolve(&task_context).collect(), - position: snapshot.buffer_snapshot().anchor_before(Point::new( - multibuffer_point.row, - tasks.column, - )), - }); - let debug_scenarios = editor - .update(cx, |editor, cx| { - editor.debug_scenarios(&resolved_tasks, &buffer, cx) - })? - .await; - anyhow::Ok((resolved_tasks, debug_scenarios, task_context)) - } - }) - } - }; - - let toggle_task = cx.spawn_in(window, async move |editor, cx| { - let (resolved_tasks, debug_scenarios, task_context) = runnable_task.await?; - - let code_actions = if let Some(CodeActionSource::RunMenu(_)) = &deployed_from { - None - } else { - editor.update(cx, |editor, _cx| match &editor.code_actions_for_selection { - CodeActionsForSelection::None => None, - CodeActionsForSelection::Fetching(task) => Some(task.clone()), - CodeActionsForSelection::Ready(action_fetch_ready) => { - Some(Task::ready(Some(action_fetch_ready.clone())).shared()) - } - })? - }; - let code_actions = match code_actions { - Some(code_actions) => code_actions - .await - .filter(|ActionFetchReady { location, .. }| { - let snapshot = location.buffer.read_with(cx, |buffer, _| buffer.snapshot()); - let point_range = location.range.to_point(&snapshot); - (point_range.start.row..=point_range.end.row).contains(&buffer_row) - }) - .map(|ActionFetchReady { actions, .. }| actions), - None => None, - }; - - editor.update_in(cx, |editor, window, cx| { - let spawn_straight_away = quick_launch - && resolved_tasks - .as_ref() - .is_some_and(|tasks| tasks.templates.len() == 1) - && code_actions - .as_ref() - .is_none_or(|actions| actions.is_empty()) - && debug_scenarios.is_empty(); - - crate::hover_popover::hide_hover(editor, cx); - let actions = CodeActionContents::new( - resolved_tasks, - code_actions, - debug_scenarios, - task_context.unwrap_or_default(), - ); - - // Don't show the menu if there are no actions available - if actions.is_empty() { - cx.notify(); - return Task::ready(Ok(())); - } - - *editor.context_menu.borrow_mut() = - Some(CodeContextMenu::CodeActions(CodeActionsMenu { - buffer, - actions, - selected_item: Default::default(), - scroll_handle: UniformListScrollHandle::default(), - deployed_from, - })); - cx.notify(); - if spawn_straight_away - && let Some(task) = editor.confirm_code_action( - &ConfirmCodeAction { item_ix: Some(0) }, - window, - cx, - ) - { - return task; - } - - Task::ready(Ok(())) - }) - }); - self.runnables_for_selection_toggle = cx.background_spawn(async move { - match toggle_task.await { - Ok(code_action_spawn) => match code_action_spawn.await { - Ok(()) => {} - Err(e) => log::error!("failed to spawn a toggled code action: {e:#}"), - }, - Err(e) => log::error!("failed to toggle code actions: {e:#}"), - } - }) - } - - fn debug_scenarios( - &mut self, - resolved_tasks: &Option, - buffer: &Entity, - cx: &mut App, - ) -> Task> { - maybe!({ - let project = self.project()?; - let dap_store = project.read(cx).dap_store(); - let mut scenarios = vec![]; - let resolved_tasks = resolved_tasks.as_ref()?; - let buffer = buffer.read(cx); - let language = buffer.language()?; - let debug_adapter = LanguageSettings::for_buffer(&buffer, cx) - .debuggers - .first() - .map(SharedString::from) - .or_else(|| language.config().debuggers.first().map(SharedString::from))?; - - dap_store.update(cx, |dap_store, cx| { - for (_, task) in &resolved_tasks.templates { - let maybe_scenario = dap_store.debug_scenario_for_build_task( - task.original_task().clone(), - debug_adapter.clone().into(), - task.display_label().to_owned().into(), - cx, - ); - scenarios.push(maybe_scenario); - } - }); - Some(cx.background_spawn(async move { - futures::future::join_all(scenarios) - .await - .into_iter() - .flatten() - .collect::>() - })) - }) - .unwrap_or_else(|| Task::ready(vec![])) - } - - pub fn confirm_code_action( - &mut self, - action: &ConfirmCodeAction, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - if self.read_only(cx) { - return None; - } - - let actions_menu = - if let CodeContextMenu::CodeActions(menu) = self.hide_context_menu(window, cx)? { - menu - } else { - return None; - }; - - let action_ix = action.item_ix.unwrap_or(actions_menu.selected_item); - let action = actions_menu.actions.get(action_ix)?; - let title = action.label(); - let buffer = actions_menu.buffer; - let workspace = self.workspace()?; - - match action { - CodeActionsItem::Task(task_source_kind, resolved_task) => { - workspace.update(cx, |workspace, cx| { - workspace.schedule_resolved_task( - task_source_kind, - resolved_task, - false, - window, - cx, - ); - - Some(Task::ready(Ok(()))) - }) - } - CodeActionsItem::CodeAction { action, provider } => { - if code_lens::try_handle_client_command(&action, self, &workspace, window, cx) { - return Some(Task::ready(Ok(()))); - } - - let apply_code_action = - provider.apply_code_action(buffer, action, true, window, cx); - let workspace = workspace.downgrade(); - Some(cx.spawn_in(window, async move |editor, cx| { - let project_transaction = apply_code_action.await?; - Self::open_project_transaction( - &editor, - workspace, - project_transaction, - title, - cx, - ) - .await - })) - } - CodeActionsItem::DebugScenario(scenario) => { - let context = actions_menu.actions.context.into(); - - workspace.update(cx, |workspace, cx| { - dap::send_telemetry(&scenario, TelemetrySpawnLocation::Gutter, cx); - workspace.start_debug_session( - scenario, - context, - Some(buffer), - None, - window, - cx, - ); - }); - Some(Task::ready(Ok(()))) - } - } - } - - fn open_transaction_for_hidden_buffers( - workspace: Entity, - transaction: ProjectTransaction, - title: String, - window: &mut Window, - cx: &mut Context, - ) { - if transaction.0.is_empty() { - return; - } - - let edited_buffers_already_open = { - let other_editors: Vec> = workspace - .read(cx) - .panes() - .iter() - .flat_map(|pane| pane.read(cx).items_of_type::()) - .filter(|editor| editor.entity_id() != cx.entity_id()) - .collect(); - - transaction.0.keys().all(|buffer| { - other_editors.iter().any(|editor| { - let multi_buffer = editor.read(cx).buffer(); - multi_buffer.read(cx).is_singleton() - && multi_buffer - .read(cx) - .as_singleton() - .map_or(false, |singleton| { - singleton.entity_id() == buffer.entity_id() - }) - }) - }) - }; - if !edited_buffers_already_open { - let workspace = workspace.downgrade(); - cx.defer_in(window, move |_, window, cx| { - cx.spawn_in(window, async move |editor, cx| { - Self::open_project_transaction(&editor, workspace, transaction, title, cx) - .await - .ok() - }) - .detach(); - }); - } - } - - pub async fn open_project_transaction( - editor: &WeakEntity, - workspace: WeakEntity, - transaction: ProjectTransaction, - title: String, - cx: &mut AsyncWindowContext, - ) -> Result<()> { - let mut entries = transaction.0.into_iter().collect::>(); - cx.update(|_, cx| { - entries.sort_unstable_by_key(|(buffer, _)| { - buffer.read(cx).file().map(|f| f.path().clone()) - }); - })?; - if entries.is_empty() { - return Ok(()); - } - - // If the project transaction's edits are all contained within this editor, then - // avoid opening a new editor to display them. - - if let [(buffer, transaction)] = &*entries { - let cursor_excerpt = editor.update(cx, |editor, cx| { - let snapshot = editor.buffer().read(cx).snapshot(cx); - let head = editor.selections.newest_anchor().head(); - let (buffer_snapshot, excerpt_range) = snapshot.excerpt_containing(head..head)?; - if buffer_snapshot.remote_id() != buffer.read(cx).remote_id() { - return None; - } - Some(excerpt_range) - })?; - - if let Some(excerpt_range) = cursor_excerpt { - let all_edits_within_excerpt = buffer.read_with(cx, |buffer, _| { - let excerpt_range = excerpt_range.context.to_offset(buffer); - buffer - .edited_ranges_for_transaction::(transaction) - .all(|range| { - excerpt_range.start <= range.start && excerpt_range.end >= range.end - }) - }); - - if all_edits_within_excerpt { - return Ok(()); - } - } - } + } let mut ranges_to_highlight = Vec::new(); let excerpt_buffer = cx.new(|cx| { @@ -7304,204 +5987,29 @@ impl Editor { let text_range = buffer_snapshot.anchor_range_inside(range); let start = snapshot.anchor_in_buffer(text_range.start)?; let end = snapshot.anchor_in_buffer(text_range.end)?; - Some(start..end) - })); - } - multibuffer.push_transaction(entries.iter().map(|(b, t)| (b, t)), cx); - multibuffer - }); - - workspace.update_in(cx, |workspace, window, cx| { - let project = workspace.project().clone(); - let editor = - cx.new(|cx| Editor::for_multibuffer(excerpt_buffer, Some(project), window, cx)); - workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); - editor.update(cx, |editor, cx| { - editor.highlight_background( - HighlightKey::Editor, - &ranges_to_highlight, - |_, theme| theme.colors().editor_highlighted_line_background, - cx, - ); - }); - })?; - - Ok(()) - } - - pub fn add_code_action_provider( - &mut self, - provider: Rc, - window: &mut Window, - cx: &mut Context, - ) { - if self - .code_action_providers - .iter() - .any(|existing_provider| existing_provider.id() == provider.id()) - { - return; - } - - self.code_action_providers.push(provider); - self.refresh_code_actions_for_selection(window, cx); - } - - pub fn remove_code_action_provider( - &mut self, - id: Arc, - window: &mut Window, - cx: &mut Context, - ) { - self.code_action_providers - .retain(|provider| provider.id() != id); - self.refresh_code_actions_for_selection(window, cx); - } - - pub fn code_actions_enabled_for_toolbar(&self, cx: &App) -> bool { - !self.code_action_providers.is_empty() - && EditorSettings::get_global(cx).toolbar.code_actions - } - - pub fn has_available_code_actions_for_selection(&self) -> bool { - if let CodeActionsForSelection::Ready(ready) = &self.code_actions_for_selection { - !ready.actions.is_empty() - } else { - false - } - } - - fn render_inline_code_actions( - &self, - icon_size: ui::IconSize, - display_row: DisplayRow, - is_active: bool, - cx: &mut Context, - ) -> AnyElement { - let show_tooltip = !self.context_menu_visible(); - IconButton::new("inline_code_actions", ui::IconName::BoltFilled) - .icon_size(icon_size) - .shape(ui::IconButtonShape::Square) - .icon_color(ui::Color::Hidden) - .toggle_state(is_active) - .when(show_tooltip, |this| { - this.tooltip({ - let focus_handle = self.focus_handle.clone(); - move |_window, cx| { - Tooltip::for_action_in( - "Toggle Code Actions", - &ToggleCodeActions { - deployed_from: None, - quick_launch: false, - }, - &focus_handle, - cx, - ) - } - }) - }) - .on_click(cx.listener(move |editor, _: &ClickEvent, window, cx| { - window.focus(&editor.focus_handle(cx), cx); - editor.toggle_code_actions( - &crate::actions::ToggleCodeActions { - deployed_from: Some(crate::actions::CodeActionSource::Indicator( - display_row, - )), - quick_launch: false, - }, - window, - cx, - ); - })) - .into_any_element() - } - - pub fn context_menu(&self) -> &RefCell> { - &self.context_menu - } - - fn refresh_code_actions_for_selection(&mut self, window: &mut Window, cx: &mut Context) { - self.code_actions_for_selection = CodeActionsForSelection::Fetching( - cx.spawn_in(window, async move |editor, cx| { - cx.background_executor() - .timer(CODE_ACTIONS_DEBOUNCE_TIMEOUT) - .await; - - let (start_buffer, start, _, end, _newest_selection) = editor - .update(cx, |editor, cx| { - let newest_selection = editor.selections.newest_anchor().clone(); - if newest_selection.head().diff_base_anchor().is_some() { - return None; - } - let display_snapshot = editor.display_snapshot(cx); - let newest_selection_adjusted = - editor.selections.newest_adjusted(&display_snapshot); - let buffer = editor.buffer.read(cx); - - let (start_buffer, start) = - buffer.text_anchor_for_position(newest_selection_adjusted.start, cx)?; - let (end_buffer, end) = - buffer.text_anchor_for_position(newest_selection_adjusted.end, cx)?; - - Some((start_buffer, start, end_buffer, end, newest_selection)) - }) - .ok() - .flatten() - .filter(|(start_buffer, _, end_buffer, _, _)| start_buffer == end_buffer)?; - - let (providers, tasks) = editor - .update_in(cx, |editor, window, cx| { - let providers = editor.code_action_providers.clone(); - let tasks = editor - .code_action_providers - .iter() - .map(|provider| { - provider.code_actions(&start_buffer, start..end, window, cx) - }) - .collect::>(); - (providers, tasks) - }) - .ok()?; + Some(start..end) + })); + } + multibuffer.push_transaction(entries.iter().map(|(b, t)| (b, t)), cx); + multibuffer + }); - let mut actions = Vec::new(); - for (provider, provider_actions) in - providers.into_iter().zip(future::join_all(tasks).await) - { - if let Some(provider_actions) = provider_actions.log_err() { - actions.extend(provider_actions.into_iter().map(|action| { - AvailableCodeAction { - action, - provider: provider.clone(), - } - })); - } - } + workspace.update_in(cx, |workspace, window, cx| { + let project = workspace.project().clone(); + let editor = + cx.new(|cx| Editor::for_multibuffer(excerpt_buffer, Some(project), window, cx)); + workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); + editor.update(cx, |editor, cx| { + editor.highlight_background( + HighlightKey::Editor, + &ranges_to_highlight, + |_, theme| theme.colors().editor_highlighted_line_background, + cx, + ); + }); + })?; - editor - .update(cx, |editor, cx| { - let new_actions = if actions.is_empty() { - editor.code_actions_for_selection = CodeActionsForSelection::None; - None - } else { - let new_actions = ActionFetchReady { - location: Location { - buffer: start_buffer, - range: start..end, - }, - actions: Rc::from(actions), - }; - editor.code_actions_for_selection = - CodeActionsForSelection::Ready(new_actions.clone()); - Some(new_actions) - }; - cx.notify(); - new_actions - }) - .ok() - .flatten() - }) - .shared(), - ); + Ok(()) } fn start_inline_blame_timer(&mut self, window: &mut Window, cx: &mut Context) { @@ -19752,10 +18260,6 @@ impl Editor { window.show_character_palette(); } - pub fn disable_word_completions(&mut self) { - self.word_completions_enabled = false; - } - pub fn toggle_minimap( &mut self, _: &ToggleMinimap, @@ -24932,13 +23436,6 @@ impl Editor { Some(gpui::Point::new(source_x, source_y)) } - pub fn has_visible_completions_menu(&self) -> bool { - !self.edit_prediction_preview_is_active() - && self.context_menu.borrow().as_ref().is_some_and(|menu| { - menu.visible() && matches!(menu, CodeContextMenu::Completions(_)) - }) - } - pub fn register_addon(&mut self, instance: T) { if self.mode.is_minimap() { return; @@ -26242,478 +24739,6 @@ pub trait SemanticsProvider { ) -> Option>>; } -pub trait CompletionProvider { - fn completions( - &self, - buffer: &Entity, - buffer_position: text::Anchor, - trigger: CompletionContext, - window: &mut Window, - cx: &mut Context, - ) -> Task>>; - - fn resolve_completions( - &self, - _buffer: Entity, - _completion_indices: Vec, - _completions: Rc>>, - _cx: &mut Context, - ) -> Task> { - Task::ready(Ok(false)) - } - - fn apply_additional_edits_for_completion( - &self, - _buffer: Entity, - _completions: Rc>>, - _completion_index: usize, - _push_to_history: bool, - _all_commit_ranges: Vec>, - _cx: &mut Context, - ) -> Task>> { - Task::ready(Ok(None)) - } - - fn is_completion_trigger( - &self, - buffer: &Entity, - position: language::Anchor, - text: &str, - trigger_in_words: bool, - cx: &mut Context, - ) -> bool; - - fn selection_changed(&self, _mat: Option<&StringMatch>, _window: &mut Window, _cx: &mut App) {} - - fn sort_completions(&self) -> bool { - true - } - - fn filter_completions(&self) -> bool { - true - } - - fn show_snippets(&self) -> bool { - false - } -} - -pub trait CodeActionProvider { - fn id(&self) -> Arc; - - fn code_actions( - &self, - buffer: &Entity, - range: Range, - window: &mut Window, - cx: &mut App, - ) -> Task>>; - - fn apply_code_action( - &self, - buffer_handle: Entity, - action: CodeAction, - push_to_history: bool, - window: &mut Window, - cx: &mut App, - ) -> Task>; -} - -impl CodeActionProvider for Entity { - fn id(&self) -> Arc { - "project".into() - } - - fn code_actions( - &self, - buffer: &Entity, - range: Range, - _window: &mut Window, - cx: &mut App, - ) -> Task>> { - self.update(cx, |project, cx| { - let code_lens_actions = if EditorSettings::get_global(cx).code_lens.show_in_menu() { - Some(project.code_lens_actions(buffer, range.clone(), cx)) - } else { - None - }; - let code_actions = project.code_actions(buffer, range, None, cx); - cx.background_spawn(async move { - let code_lens_actions = match code_lens_actions { - Some(task) => task.await.context("code lens fetch")?.unwrap_or_default(), - None => Vec::new(), - }; - let code_actions = code_actions - .await - .context("code action fetch")? - .unwrap_or_default(); - Ok(code_lens_actions.into_iter().chain(code_actions).collect()) - }) - }) - } - - fn apply_code_action( - &self, - buffer_handle: Entity, - action: CodeAction, - push_to_history: bool, - _window: &mut Window, - cx: &mut App, - ) -> Task> { - self.update(cx, |project, cx| { - project.apply_code_action(buffer_handle, action, push_to_history, cx) - }) - } -} - -fn has_strong_snippet_prefix_match( - project: &Project, - buffer: &Entity, - buffer_anchor: text::Anchor, - classifier: &CharClassifier, - query: &str, - cx: &App, -) -> bool { - if query.chars().take(2).count() < 2 { - return false; - } - - let query = query.to_lowercase(); - let is_word_char = |character| classifier.is_word(character); - let languages = buffer.read(cx).languages_at(buffer_anchor); - let snippet_store = project.snippets().read(cx); - - languages.iter().any(|language| { - snippet_store - .snippets_for(Some(language.lsp_id()), cx) - .iter() - .flat_map(|snippet| snippet.prefix.iter()) - .flat_map(|prefix| snippet_candidate_suffixes(prefix, &is_word_char)) - .any(|candidate| candidate.to_lowercase().starts_with(&query)) - }) -} - -fn snippet_completions( - project: &Project, - buffer: &Entity, - buffer_anchor: text::Anchor, - classifier: CharClassifier, - cx: &mut App, -) -> Task> { - let languages = buffer.read(cx).languages_at(buffer_anchor); - let snippet_store = project.snippets().read(cx); - - let scopes: Vec<_> = languages - .iter() - .filter_map(|language| { - let language_name = language.lsp_id(); - let snippets = snippet_store.snippets_for(Some(language_name), cx); - - if snippets.is_empty() { - None - } else { - Some((language.default_scope(), snippets)) - } - }) - .collect(); - - if scopes.is_empty() { - return Task::ready(Ok(CompletionResponse { - completions: vec![], - display_options: CompletionDisplayOptions::default(), - is_incomplete: false, - })); - } - - let snapshot = buffer.read(cx).text_snapshot(); - let executor = cx.background_executor().clone(); - - cx.background_spawn(async move { - let is_word_char = |c| classifier.is_word(c); - - let mut is_incomplete = false; - let mut completions: Vec = Vec::new(); - - const MAX_PREFIX_LEN: usize = 128; - let buffer_offset = text::ToOffset::to_offset(&buffer_anchor, &snapshot); - let window_start = buffer_offset.saturating_sub(MAX_PREFIX_LEN); - let window_start = snapshot.clip_offset(window_start, Bias::Left); - - let max_buffer_window: String = snapshot - .text_for_range(window_start..buffer_offset) - .collect(); - - if max_buffer_window.is_empty() { - return Ok(CompletionResponse { - completions: vec![], - display_options: CompletionDisplayOptions::default(), - is_incomplete: true, - }); - } - - for (_scope, snippets) in scopes.into_iter() { - // Sort snippets by word count to match longer snippet prefixes first. - let mut sorted_snippet_candidates = snippets - .iter() - .enumerate() - .flat_map(|(snippet_ix, snippet)| { - snippet - .prefix - .iter() - .enumerate() - .map(move |(prefix_ix, prefix)| { - let word_count = - snippet_candidate_suffixes(prefix, &is_word_char).count(); - ((snippet_ix, prefix_ix), prefix, word_count) - }) - }) - .collect_vec(); - sorted_snippet_candidates - .sort_unstable_by_key(|(_, _, word_count)| Reverse(*word_count)); - - // Each prefix may be matched multiple times; the completion menu must filter out duplicates. - - let buffer_windows = snippet_candidate_suffixes(&max_buffer_window, &is_word_char) - .take( - sorted_snippet_candidates - .first() - .map(|(_, _, word_count)| *word_count) - .unwrap_or_default(), - ) - .collect_vec(); - - const MAX_RESULTS: usize = 100; - // Each match also remembers how many characters from the buffer it consumed - let mut matches: Vec<(StringMatch, usize)> = vec![]; - - let mut snippet_list_cutoff_index = 0; - for (buffer_index, buffer_window) in buffer_windows.iter().enumerate().rev() { - let word_count = buffer_index + 1; - // Increase `snippet_list_cutoff_index` until we have all of the - // snippets with sufficiently many words. - while sorted_snippet_candidates - .get(snippet_list_cutoff_index) - .is_some_and(|(_ix, _prefix, snippet_word_count)| { - *snippet_word_count >= word_count - }) - { - snippet_list_cutoff_index += 1; - } - - // Take only the candidates with at least `word_count` many words - let snippet_candidates_at_word_len = - &sorted_snippet_candidates[..snippet_list_cutoff_index]; - - let candidates = snippet_candidates_at_word_len - .iter() - .map(|(_snippet_ix, prefix, _snippet_word_count)| prefix) - .enumerate() // index in `sorted_snippet_candidates` - // First char must match - .filter(|(_ix, prefix)| { - itertools::equal( - prefix - .chars() - .next() - .into_iter() - .flat_map(|c| c.to_lowercase()), - buffer_window - .chars() - .next() - .into_iter() - .flat_map(|c| c.to_lowercase()), - ) - }) - .map(|(ix, prefix)| StringMatchCandidate::new(ix, prefix)) - .collect::>(); - - matches.extend( - fuzzy::match_strings( - &candidates, - &buffer_window, - buffer_window.chars().any(|c| c.is_uppercase()), - true, - MAX_RESULTS - matches.len(), // always prioritize longer snippets - &Default::default(), - executor.clone(), - ) - .await - .into_iter() - .map(|string_match| (string_match, buffer_window.len())), - ); - - if matches.len() >= MAX_RESULTS { - break; - } - } - - let to_lsp = |point: &text::Anchor| { - let end = text::ToPointUtf16::to_point_utf16(point, &snapshot); - point_to_lsp(end) - }; - let lsp_end = to_lsp(&buffer_anchor); - - if matches.len() >= MAX_RESULTS { - is_incomplete = true; - } - - completions.extend(matches.iter().map(|(string_match, buffer_window_len)| { - let ((snippet_index, prefix_index), matching_prefix, _snippet_word_count) = - sorted_snippet_candidates[string_match.candidate_id]; - let snippet = &snippets[snippet_index]; - let start = buffer_offset - buffer_window_len; - let start = snapshot.anchor_before(start); - let range = start..buffer_anchor; - let lsp_start = to_lsp(&start); - let lsp_range = lsp::Range { - start: lsp_start, - end: lsp_end, - }; - Completion { - replace_range: range, - new_text: snippet.body.clone(), - source: CompletionSource::Lsp { - insert_range: None, - server_id: LanguageServerId(usize::MAX), - resolved: true, - lsp_completion: Box::new(lsp::CompletionItem { - label: snippet.prefix.first().unwrap().clone(), - kind: Some(CompletionItemKind::SNIPPET), - label_details: snippet.description.as_ref().map(|description| { - lsp::CompletionItemLabelDetails { - detail: Some(description.clone()), - description: None, - } - }), - insert_text_format: Some(InsertTextFormat::SNIPPET), - text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( - lsp::InsertReplaceEdit { - new_text: snippet.body.clone(), - insert: lsp_range, - replace: lsp_range, - }, - )), - filter_text: Some(snippet.body.clone()), - sort_text: Some(char::MAX.to_string()), - ..lsp::CompletionItem::default() - }), - lsp_defaults: None, - }, - label: CodeLabel { - text: matching_prefix.clone(), - runs: Vec::new(), - filter_range: 0..matching_prefix.len(), - }, - icon_path: None, - documentation: Some(CompletionDocumentation::SingleLineAndMultiLinePlainText { - single_line: snippet.name.clone().into(), - plain_text: snippet - .description - .clone() - .map(|description| description.into()), - }), - insert_text_mode: None, - confirm: None, - match_start: Some(start), - snippet_deduplication_key: Some((snippet_index, prefix_index)), - } - })); - } - - Ok(CompletionResponse { - completions, - display_options: CompletionDisplayOptions::default(), - is_incomplete, - }) - }) -} - -impl CompletionProvider for Entity { - fn completions( - &self, - buffer: &Entity, - buffer_position: text::Anchor, - options: CompletionContext, - _window: &mut Window, - cx: &mut Context, - ) -> Task>> { - self.update(cx, |project, cx| { - let task = project.completions(buffer, buffer_position, options, cx); - cx.background_spawn(task) - }) - } - - fn resolve_completions( - &self, - buffer: Entity, - completion_indices: Vec, - completions: Rc>>, - cx: &mut Context, - ) -> Task> { - self.update(cx, |project, cx| { - project.lsp_store().update(cx, |lsp_store, cx| { - lsp_store.resolve_completions(buffer, completion_indices, completions, cx) - }) - }) - } - - fn apply_additional_edits_for_completion( - &self, - buffer: Entity, - completions: Rc>>, - completion_index: usize, - push_to_history: bool, - all_commit_ranges: Vec>, - cx: &mut Context, - ) -> Task>> { - self.update(cx, |project, cx| { - project.lsp_store().update(cx, |lsp_store, cx| { - lsp_store.apply_additional_edits_for_completion( - buffer, - completions, - completion_index, - push_to_history, - all_commit_ranges, - cx, - ) - }) - }) - } - - fn is_completion_trigger( - &self, - buffer: &Entity, - position: language::Anchor, - text: &str, - trigger_in_words: bool, - cx: &mut Context, - ) -> bool { - let mut chars = text.chars(); - let char = if let Some(char) = chars.next() { - char - } else { - return false; - }; - if chars.next().is_some() { - return false; - } - - let buffer = buffer.read(cx); - let snapshot = buffer.snapshot(); - let classifier = snapshot - .char_classifier_at(position) - .scope_context(Some(CharScopeContext::Completion)); - if trigger_in_words && classifier.is_word(char) { - return true; - } - - buffer.completion_triggers().contains(text) - } - - fn show_snippets(&self) -> bool { - true - } -} - impl SemanticsProvider for WeakEntity { fn hover( &self, @@ -28115,53 +26140,6 @@ pub fn styled_runs_for_code_label<'a>( ) } -pub(crate) fn split_words(text: &str) -> impl std::iter::Iterator + '_ { - let mut prev_index = 0; - let mut prev_codepoint: Option = None; - text.char_indices() - .chain([(text.len(), '\0')]) - .filter_map(move |(index, codepoint)| { - let prev_codepoint = prev_codepoint.replace(codepoint)?; - let is_boundary = index == text.len() - || !prev_codepoint.is_uppercase() && codepoint.is_uppercase() - || !prev_codepoint.is_alphanumeric() && codepoint.is_alphanumeric(); - if is_boundary { - let chunk = &text[prev_index..index]; - prev_index = index; - Some(chunk) - } else { - None - } - }) -} - -/// Given a string of text immediately before the cursor, iterates over possible -/// strings a snippet could match to. More precisely: returns an iterator over -/// suffixes of `text` created by splitting at word boundaries (before & after -/// every non-word character). -/// -/// Shorter suffixes are returned first. -pub(crate) fn snippet_candidate_suffixes<'a>( - text: &'a str, - is_word_char: &'a dyn Fn(char) -> bool, -) -> impl std::iter::Iterator + 'a { - let mut prev_index = text.len(); - let mut prev_codepoint = None; - text.char_indices() - .rev() - .chain([(0, '\0')]) - .filter_map(move |(index, codepoint)| { - let prev_index = std::mem::replace(&mut prev_index, index); - let prev_codepoint = prev_codepoint.replace(codepoint)?; - if is_word_char(prev_codepoint) && is_word_char(codepoint) { - None - } else { - let chunk = &text[prev_index..]; // go to end of string - Some(chunk) - } - }) -} - pub trait RangeToAnchorExt: Sized { fn to_anchors(self, snapshot: &MultiBufferSnapshot) -> Range; From 7556cf8ced19cae275fb4d6678858050d4d51df4 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 7 May 2026 15:47:13 +0200 Subject: [PATCH 15/33] agent: Fix race-condition for LSP tool registration (#56044) This fixes a race condition where the thread would not get the LSP tools at startup if the feature flag was not resolved yet. We now always add the tools, but filter them out when we start a new turn if the feature flag is not set. Release Notes: - N/A --- crates/agent/src/tests/mod.rs | 99 +++++++++++++++++++++++++++++++++++ crates/agent/src/thread.rs | 42 ++++++++------- 2 files changed, 123 insertions(+), 18 deletions(-) diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 57cec0bc5d07a9..e4dd5a24257501 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -5545,6 +5545,105 @@ async fn test_max_subagent_depth_prevents_tool_registration(cx: &mut TestAppCont }); } +#[gpui::test] +async fn test_lsp_tools_gated_by_feature_flag(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/test"), json!({})).await; + let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + let project_context = cx.new(|_cx| ProjectContext::default()); + let context_server_store = project.read_with(cx, |project, _| project.context_server_store()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let environment = Rc::new(cx.update(|cx| { + FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx)) + })); + + let thread = cx.new(|cx| { + let mut thread = Thread::new( + project, + project_context, + context_server_registry, + Templates::new(), + Some(model.clone() as Arc), + cx, + ); + thread.add_default_tools(environment, cx); + thread + }); + + let lsp_tool_names = [ + FindReferencesTool::NAME, + GetCodeActionsTool::NAME, + ApplyCodeActionTool::NAME, + GoToDefinitionTool::NAME, + RenameTool::NAME, + ]; + + // All LSP tools should be registered on the thread regardless of the flag, + // since the feature flag now only controls exposure to the model rather + // than registration. + thread.read_with(cx, |thread, _| { + for name in &lsp_tool_names { + assert!( + thread.has_registered_tool(name), + "expected LSP tool {name} to be registered" + ); + } + }); + + // Without the `lsp-tool` flag, sending a message should produce a + // completion request whose tool list excludes the LSP tools. + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["hello"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + let completion = model.pending_completions().pop().unwrap(); + let tool_names = tool_names_for_completion(&completion); + for name in &lsp_tool_names { + assert!( + !tool_names.iter().any(|t| t == name), + "expected LSP tool {name} to be hidden without the lsp-tool flag, \ + but completion tools were: {tool_names:?}" + ); + } + // Sanity check: a non-LSP default tool should still be exposed. + assert!( + tool_names.iter().any(|t| t == ReadFileTool::NAME), + "expected non-LSP tools to still be exposed, got: {tool_names:?}" + ); + model.end_last_completion_stream(); + cx.run_until_parked(); + + // Enable the `lsp-tool` flag and send another message; the LSP tools + // should now appear in the completion request. + cx.update(|cx| { + cx.update_flags(false, vec!["lsp-tool".to_string()]); + }); + + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["hello again"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + let completion = model.pending_completions().pop().unwrap(); + let tool_names = tool_names_for_completion(&completion); + for name in &lsp_tool_names { + assert!( + tool_names.iter().any(|t| t == name), + "expected LSP tool {name} to be exposed when lsp-tool flag is on, \ + but completion tools were: {tool_names:?}" + ); + } +} + #[gpui::test] async fn test_parent_cancel_stops_subagent(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 78a4b2fd488918..ef03f47a8d3703 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -1577,20 +1577,19 @@ impl Thread { self.add_tool(WebSearchTool); self.add_tool(DiagnosticsTool::new(self.project.clone())); - if cx.has_flag::() { - let code_action_store: CodeActionStore = cx.new(|_cx| None); - self.add_tool(FindReferencesTool::new(self.project.clone())); - self.add_tool(GetCodeActionsTool::new( - self.project.clone(), - code_action_store.clone(), - )); - self.add_tool(ApplyCodeActionTool::new( - self.project.clone(), - code_action_store, - )); - self.add_tool(GoToDefinitionTool::new(self.project.clone())); - self.add_tool(RenameTool::new(self.project.clone())); - } + + let code_action_store: CodeActionStore = cx.new(|_cx| None); + self.add_tool(FindReferencesTool::new(self.project.clone())); + self.add_tool(GetCodeActionsTool::new( + self.project.clone(), + code_action_store.clone(), + )); + self.add_tool(ApplyCodeActionTool::new( + self.project.clone(), + code_action_store, + )); + self.add_tool(GoToDefinitionTool::new(self.project.clone())); + self.add_tool(RenameTool::new(self.project.clone())); if self.depth() < MAX_SUBAGENT_DEPTH { self.add_tool(SpawnAgentTool::new(environment)); @@ -2894,6 +2893,17 @@ impl Thread { None } }) + .filter(|(tool_name, _)| { + cx.has_flag::() + || !matches!( + tool_name.as_ref(), + FindReferencesTool::NAME + | GetCodeActionsTool::NAME + | ApplyCodeActionTool::NAME + | GoToDefinitionTool::NAME + | RenameTool::NAME + ) + }) .collect::>(); let mut context_server_tools = Vec::new(); @@ -2957,10 +2967,6 @@ impl Thread { self.tools.contains_key(name) } - pub fn registered_tool_names(&self) -> Vec { - self.tools.keys().cloned().collect() - } - pub(crate) fn register_running_subagent(&mut self, subagent: WeakEntity) { self.running_subagents.push(subagent); } From 5c0b33f72e75281ce70c9866d90cc0f6a7121c22 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Thu, 7 May 2026 16:09:39 +0200 Subject: [PATCH 16/33] gpui_windows: Avoid process-wide priority elevation (#56050) We were incorrectly calling this with a thread handle, additionally changing the process priority here doesn't make sense, so just drop this. Release Notes: - N/A or Added/Fixed/Improved ... --------- Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com> --- crates/gpui_windows/src/dispatcher.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/crates/gpui_windows/src/dispatcher.rs b/crates/gpui_windows/src/dispatcher.rs index 60b9898cef3076..2b2bf402d2b147 100644 --- a/crates/gpui_windows/src/dispatcher.rs +++ b/crates/gpui_windows/src/dispatcher.rs @@ -13,10 +13,7 @@ use windows::{ Win32::{ Foundation::{LPARAM, WPARAM}, Media::{timeBeginPeriod, timeEndPeriod}, - System::Threading::{ - GetCurrentThread, HIGH_PRIORITY_CLASS, SetPriorityClass, SetThreadPriority, - THREAD_PRIORITY_TIME_CRITICAL, - }, + System::Threading::{GetCurrentThread, SetThreadPriority, THREAD_PRIORITY_TIME_CRITICAL}, UI::WindowsAndMessaging::PostMessageW, }, }; @@ -163,12 +160,7 @@ impl PlatformDispatcher for WindowsDispatcher { // SAFETY: always safe to call let thread_handle = unsafe { GetCurrentThread() }; - // SAFETY: thread_handle is a valid handle to a thread - unsafe { SetPriorityClass(thread_handle, HIGH_PRIORITY_CLASS) } - .context("thread priority class") - .log_err(); - - // SAFETY: thread_handle is a valid handle to a thread + // SAFETY: thread_handle is a valid handle to the current thread unsafe { SetThreadPriority(thread_handle, THREAD_PRIORITY_TIME_CRITICAL) } .context("thread priority") .log_err(); From 07c1943b439715321c0fc5f539eaa2081d806aa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Craig?= Date: Thu, 7 May 2026 11:41:30 -0300 Subject: [PATCH 17/33] Add telemetry events for agent profile usage and configuration (#56054) Release Notes: - N/A --- .../manage_profiles_modal.rs | 26 ++++++++++++++++++- crates/agent_ui/src/profile_selector.rs | 5 ++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs index 9e042b8ad66111..e81c14ca0e5153 100644 --- a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs +++ b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs @@ -218,6 +218,11 @@ impl ManageProfilesModal { window: &mut Window, cx: &mut Context, ) { + telemetry::event!( + "Agent Profile Default Model Configured", + profile_id = profile_id.as_str(), + is_builtin = builtin_profiles::is_builtin(&profile_id) + ); let fs = self.fs.clone(); let profile_id_for_closure = profile_id.clone(); @@ -314,6 +319,11 @@ impl ManageProfilesModal { window: &mut Window, cx: &mut Context, ) { + telemetry::event!( + "Agent Profile MCPs Configured", + profile_id = profile_id.as_str(), + is_builtin = builtin_profiles::is_builtin(&profile_id) + ); let settings = AgentSettings::get_global(cx); let Some(profile) = settings.profiles.get(&profile_id).cloned() else { return; @@ -350,6 +360,11 @@ impl ManageProfilesModal { window: &mut Window, cx: &mut Context, ) { + telemetry::event!( + "Agent Profile Tools Configured", + profile_id = profile_id.as_str(), + is_builtin = builtin_profiles::is_builtin(&profile_id) + ); let settings = AgentSettings::get_global(cx); let Some(profile) = settings.profiles.get(&profile_id).cloned() else { return; @@ -398,9 +413,16 @@ impl ManageProfilesModal { Mode::ChooseProfile { .. } => {} Mode::NewProfile(mode) => { let name = mode.name_editor.read(cx).text(cx); + let base_profile_id = mode.base_profile_id.clone(); let profile_id = - AgentProfile::create(name, mode.base_profile_id.clone(), self.fs.clone(), cx); + AgentProfile::create(name, base_profile_id.clone(), self.fs.clone(), cx); + telemetry::event!( + "Agent Profile Created", + profile_id = profile_id.as_str(), + is_fork = base_profile_id.is_some(), + base_profile_id = base_profile_id.as_ref().map(|id| id.as_str()) + ); self.view_profile(profile_id, window, cx); } Mode::ViewProfile(_) => {} @@ -421,6 +443,8 @@ impl ManageProfilesModal { return; } + telemetry::event!("Agent Profile Deleted", profile_id = profile_id.as_str()); + let fs = self.fs.clone(); update_settings_file(fs, cx, move |settings, _cx| { diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index 2f32d27983589f..5919abbbc97785 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -95,6 +95,11 @@ impl ProfileSelector { if let Some((next_profile_id, _)) = profiles.get_index(next_index) { self.provider.set_profile(next_profile_id.clone(), cx); + telemetry::event!( + "Agent Profile Switched", + profile_id = next_profile_id.as_str(), + source = "cycle" + ); cx.notify(); } } From bfe5dfb4a1a47b60212f5a19259973b359cbb8e8 Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Thu, 7 May 2026 23:07:36 +0800 Subject: [PATCH 18/33] gpui_wgpu: Remove redundant match arms for backend priority (#56032) Release Notes: - N/A --- crates/gpui_wgpu/src/wgpu_context.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/gpui_wgpu/src/wgpu_context.rs b/crates/gpui_wgpu/src/wgpu_context.rs index d25e1dc71c982a..9dd559939340b9 100644 --- a/crates/gpui_wgpu/src/wgpu_context.rs +++ b/crates/gpui_wgpu/src/wgpu_context.rs @@ -278,9 +278,7 @@ impl WgpuContext { }; let backend_priority: u8 = match info.backend { - wgpu::Backend::Vulkan => 0, - wgpu::Backend::Metal => 0, - wgpu::Backend::Dx12 => 0, + wgpu::Backend::Vulkan | wgpu::Backend::Metal | wgpu::Backend::Dx12 => 0, _ => 1, }; From 675ed70f59483cfe7d07e0f90dc487ec02e65c68 Mon Sep 17 00:00:00 2001 From: Sathwik Chirivelli <146921254+chirivelli@users.noreply.github.com> Date: Thu, 7 May 2026 20:51:08 +0530 Subject: [PATCH 19/33] Fix multibuffer initialization based on RHS state (#56058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This update modifies the initialization of the left-hand side multibuffer in the SplittableEditor. It now checks if the right-hand side multibuffer is a singleton and uses a `MultiBuffer::without_headers` instead. Before Screenshot: Screenshot 2026-05-07 at 7 30
16 PM After Screenshot: Screenshot 2026-05-07 at 7 32
48 PM Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Optimized multibuffer creation by conditionally using headers based on RHS state. --- crates/editor/src/split.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index 8f7ef224c53388..39c450fb9598f7 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -583,8 +583,13 @@ impl SplittableEditor { }; let project = workspace.read(cx).project().clone(); + let is_rhs_singleton = self.rhs_multibuffer.read(cx).is_singleton(); let lhs_multibuffer = cx.new(|cx| { - let mut multibuffer = MultiBuffer::new(Capability::ReadOnly); + let mut multibuffer = if is_rhs_singleton { + MultiBuffer::without_headers(Capability::ReadOnly) + } else { + MultiBuffer::new(Capability::ReadOnly) + }; multibuffer.set_all_diff_hunks_expanded(cx); multibuffer }); From 5fc8a836ddc9931474887be2e0b33d1077e031d6 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 7 May 2026 17:39:16 +0200 Subject: [PATCH 20/33] sidebar: Experimental Terminal Mode (#56063) Experiment with allowing users to manage terminal sessions along with threads in the sidebar. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --------- Co-authored-by: Bennet Bo Fenner --- Cargo.lock | 1 + assets/keymaps/default-linux.json | 6 + assets/keymaps/default-macos.json | 7 + assets/keymaps/default-windows.json | 7 + crates/agent_ui/src/agent_panel.rs | 804 +++++++++++++++++++++- crates/agent_ui/src/agent_ui.rs | 4 +- crates/agent_ui/src/conversation_view.rs | 4 +- crates/feature_flags/src/flags.rs | 12 + crates/project/src/project.rs | 9 +- crates/sidebar/Cargo.toml | 1 + crates/sidebar/src/sidebar.rs | 801 ++++++++++++++++----- crates/sidebar/src/sidebar_tests.rs | 193 +++++- crates/terminal_view/src/terminal_view.rs | 2 +- 13 files changed, 1628 insertions(+), 223 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d9a42436e3d6c4..f505b58b5f7d82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16329,6 +16329,7 @@ dependencies = [ "git", "gpui", "http_client", + "itertools 0.14.0", "language", "language_model", "log", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 9c49646b5a786c..cd1aee29c7c9a6 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1247,6 +1247,12 @@ "ctrl->": "agent::AddSelectionToThread", }, }, + { + "context": "AgentPanel && Terminal", + "bindings": { + "ctrl-n": "agent::NewThread", + }, + }, { "context": "ZedPredictModal", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index d0ac2c22e03a01..bf96104f65e740 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1316,6 +1316,13 @@ "cmd->": "agent::AddSelectionToThread", }, }, + { + "context": "AgentPanel > Terminal", + "use_key_equivalents": true, + "bindings": { + "cmd-n": "agent::NewThread", + }, + }, { "context": "RatePredictionsModal", "use_key_equivalents": true, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 66195c604fef0f..ce293452d2d6bd 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1262,6 +1262,13 @@ "ctrl-shift-.": "agent::AddSelectionToThread", }, }, + { + "context": "AgentPanel > Terminal", + "use_key_equivalents": true, + "bindings": { + "ctrl-n": "agent::NewThread", + }, + }, { "context": "Terminal && selection", "bindings": { diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index e60a4834ae2b06..b17c52818be8ad 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1,4 +1,5 @@ use std::{ + fmt, path::PathBuf, rc::Rc, sync::{ @@ -57,6 +58,7 @@ use collections::HashMap; use editor::{Editor, MultiBuffer}; use extension::ExtensionEvents; use extension_host::ExtensionStore; +use feature_flags::{AgentPanelTerminalFeatureFlag, FeatureFlagAppExt as _}; use fs::Fs; use gpui::{ Action, Anchor, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, @@ -68,7 +70,10 @@ use language_model::LanguageModelRegistry; use project::{Project, ProjectPath, Worktree}; use prompt_store::{PromptStore, UserPromptId}; use rules_library::{RulesLibrary, open_rules_library}; +use settings::TerminalDockPosition; use settings::{Settings, update_settings_file}; +use terminal::{Event as TerminalEvent, terminal_settings::TerminalSettings}; +use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; use theme_settings::ThemeSettings; use ui::{ Button, ContextMenu, ContextMenuEntry, IconButton, PopoverMenu, PopoverMenuHandle, Tab, @@ -79,6 +84,7 @@ use workspace::{ CollaboratorId, DraggedSelection, DraggedTab, PathList, SerializedPathList, ToggleWorkspaceSidebar, ToggleZoom, Workspace, WorkspaceId, dock::{DockPosition, Panel, PanelEvent}, + item::ItemEvent, }; const AGENT_PANEL_KEY: &str = "agent_panel"; @@ -96,6 +102,29 @@ impl MaxIdleRetainedThreads { } } +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +pub struct TerminalId(uuid::Uuid); + +impl TerminalId { + fn new() -> Self { + Self(uuid::Uuid::new_v4()) + } +} + +impl fmt::Display for TerminalId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +#[derive(Clone, Debug)] +pub struct AgentPanelTerminalInfo { + pub id: TerminalId, + pub title: SharedString, + pub created_at: DateTime, + pub has_notification: bool, +} + #[derive(Serialize, Deserialize)] struct LastUsedAgent { agent: Agent, @@ -152,10 +181,19 @@ fn read_legacy_serialized_panel(kvp: &KeyValueStore) -> Option(&json).log_err()) } +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +enum AgentPanelEntryKind { + #[default] + Thread, + Terminal, +} + #[derive(Serialize, Deserialize, Debug)] struct SerializedAgentPanel { selected_agent: Option, #[serde(default)] + last_created_entry_kind: AgentPanelEntryKind, + #[serde(default)] last_active_thread: Option, draft_thread_prompt: Option>, } @@ -172,9 +210,9 @@ pub fn init(cx: &mut App) { cx.observe_new( |workspace: &mut Workspace, _window, _cx: &mut Context| { workspace - .register_action(|workspace, action: &NewThread, window, cx| { + .register_action(|workspace, _: &NewThread, window, cx| { if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| panel.new_thread(action, window, cx)); + panel.update(cx, |panel, cx| panel.new_entry(Some(workspace), window, cx)); workspace.focus_panel::(window, cx); } }) @@ -411,6 +449,48 @@ pub fn init(cx: &mut App) { ) .register_action( |workspace: &mut Workspace, _: &AddSelectionToThread, window, cx| { + let active_editor = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)); + let has_editor_selection = active_editor.is_some_and(|editor| { + editor.update(cx, |editor, cx| { + editor.has_non_empty_selection(&editor.display_snapshot(cx)) + }) + }); + + let has_terminal_selection = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + .is_some_and(|terminal_view| { + terminal_view + .read(cx) + .terminal() + .read(cx) + .last_content + .selection_text + .as_ref() + .is_some_and(|text| !text.is_empty()) + }); + + let has_terminal_panel_selection = + workspace.panel::(cx).is_some_and(|panel| { + let position = match TerminalSettings::get_global(cx).dock { + TerminalDockPosition::Left => DockPosition::Left, + TerminalDockPosition::Bottom => DockPosition::Bottom, + TerminalDockPosition::Right => DockPosition::Right, + }; + let dock_is_open = + workspace.dock_at_position(position).read(cx).is_open(); + dock_is_open && !panel.read(cx).terminal_selections(cx).is_empty() + }); + + if !has_editor_selection + && !has_terminal_selection + && !has_terminal_panel_selection + { + return; + } + let Some(agent_panel) = workspace.panel::(cx) else { return; }; @@ -603,11 +683,60 @@ pub(crate) struct AgentThread { conversation_view: Entity, } +struct AgentTerminal { + view: Entity, + title_editor: Entity, + last_known_title: String, + created_at: DateTime, + has_notification: bool, + _subscriptions: Vec, +} + +impl AgentTerminal { + fn display_title(&self, cx: &App) -> SharedString { + let view = self.view.read(cx); + view.custom_title() + .map(SharedString::from) + .or_else(|| { + let breadcrumb_text = &view.terminal().read(cx).breadcrumb_text; + if breadcrumb_text.is_empty() { + None + } else { + Some(breadcrumb_text.clone().into()) + } + }) + .unwrap_or_else(|| SharedString::from(view.terminal().read(cx).title(true))) + } + + fn refresh_title(&mut self, window: &mut Window, cx: &mut App) -> bool { + let title = self.display_title(cx).to_string(); + let changed = self.last_known_title != title; + if changed { + self.last_known_title = title.clone(); + } + + let should_update_editor = { + let title_editor = self.title_editor.read(cx); + !title_editor.is_focused(window) && title_editor.text(cx) != title + }; + if should_update_editor { + self.title_editor.update(cx, |title_editor, cx| { + title_editor.set_text(title, window, cx); + }); + } + + changed + } +} + enum BaseView { Uninitialized, AgentThread { conversation_view: Entity, }, + Terminal { + terminal_id: TerminalId, + }, } impl From for BaseView { @@ -625,6 +754,7 @@ enum OverlayView { enum VisibleSurface<'a> { Uninitialized, AgentThread(&'a Entity), + Terminal(&'a Entity), Configuration(Option<&'a Entity>), } @@ -635,7 +765,10 @@ enum WhichFontSize { impl BaseView { pub fn which_font_size_used(&self) -> WhichFontSize { - WhichFontSize::AgentFont + match self { + BaseView::AgentThread { .. } => WhichFontSize::AgentFont, + BaseView::Terminal { .. } | BaseView::Uninitialized => WhichFontSize::None, + } } } @@ -663,9 +796,11 @@ pub struct AgentPanel { configuration_subscription: Option, focus_handle: FocusHandle, base_view: BaseView, + last_created_entry_kind: AgentPanelEntryKind, overlay_view: Option, draft_thread: Option>, retained_threads: HashMap>, + terminals: HashMap, new_thread_menu_handle: PopoverMenuHandle, agent_panel_menu_handle: PopoverMenuHandle, _extension_subscription: Option, @@ -690,6 +825,7 @@ impl AgentPanel { }; let selected_agent = self.selected_agent.clone(); + let last_created_entry_kind = self.last_created_entry_kind; let is_draft_active = self.active_thread_is_draft(cx); let last_active_thread = self @@ -748,6 +884,7 @@ impl AgentPanel { workspace_id, SerializedAgentPanel { selected_agent: Some(selected_agent), + last_created_entry_kind, last_active_thread, draft_thread_prompt, }, @@ -840,6 +977,7 @@ impl AgentPanel { global_last_used_agent.filter(|agent| !is_via_collab || agent.is_native()); if let Some(serialized_panel) = &serialized_panel { + panel.last_created_entry_kind = serialized_panel.last_created_entry_kind; if let Some(selected_agent) = serialized_panel.selected_agent.clone() { panel.selected_agent = selected_agent; } else if let Some(agent) = global_fallback { @@ -1009,6 +1147,7 @@ impl AgentPanel { let mut panel = Self { workspace_id, base_view, + last_created_entry_kind: AgentPanelEntryKind::Thread, overlay_view: None, workspace, user_store, @@ -1023,6 +1162,7 @@ impl AgentPanel { context_server_registry, draft_thread: None, retained_threads: HashMap::default(), + terminals: HashMap::default(), new_thread_menu_handle: PopoverMenuHandle::default(), agent_panel_menu_handle: PopoverMenuHandle::default(), @@ -1163,8 +1303,32 @@ impl AgentPanel { cx.notify(); } + pub fn new_entry( + &mut self, + workspace: Option<&Workspace>, + window: &mut Window, + cx: &mut Context, + ) { + if self.should_create_terminal_for_new_entry(cx) { + self.new_terminal(workspace, window, cx); + } else { + self.activate_new_thread(true, "agent_panel", window, cx); + } + } + pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context) { - self.activate_draft(true, "agent_panel", window, cx); + self.new_entry(None, window, cx); + } + + pub fn activate_new_thread( + &mut self, + focus: bool, + trigger: &'static str, + window: &mut Window, + cx: &mut Context, + ) { + self.set_last_created_entry_kind(AgentPanelEntryKind::Thread, cx); + self.activate_draft(focus, trigger, window, cx); } pub fn new_external_agent_thread( @@ -1176,7 +1340,311 @@ impl AgentPanel { if let Some(agent) = action.agent.clone() { self.selected_agent = agent; } - self.activate_draft(true, "agent_panel", window, cx); + self.activate_new_thread(true, "agent_panel", window, cx); + } + + pub fn new_terminal( + &mut self, + workspace: Option<&Workspace>, + window: &mut Window, + cx: &mut Context, + ) { + if !cx.has_flag::() { + return; + } + let working_directory = workspace + .map(|workspace| terminal_view::default_working_directory(workspace, cx)) + .unwrap_or_else(|| self.default_terminal_working_directory(cx)); + self.spawn_terminal(TerminalId::new(), working_directory, true, window, cx); + } + + pub fn supports_terminal(&self, cx: &App) -> bool { + cx.has_flag::() + && self.project.read(cx).supports_terminal(cx) + } + + pub fn should_create_terminal_for_new_entry(&self, cx: &App) -> bool { + self.last_created_entry_kind == AgentPanelEntryKind::Terminal && self.supports_terminal(cx) + } + + fn set_last_created_entry_kind( + &mut self, + entry_kind: AgentPanelEntryKind, + cx: &mut Context, + ) { + if self.last_created_entry_kind != entry_kind { + self.last_created_entry_kind = entry_kind; + self.serialize(cx); + } + } + + fn spawn_terminal( + &mut self, + terminal_id: TerminalId, + working_directory: Option, + focus: bool, + window: &mut Window, + cx: &mut Context, + ) { + let terminal_task = self.project.update(cx, |project, cx| { + project.create_terminal_shell(working_directory, cx) + }); + let workspace = self.workspace.clone(); + let workspace_id = self.workspace_id; + let project = self.project.downgrade(); + + cx.spawn_in(window, async move |this, cx| { + let terminal = match terminal_task.await { + Ok(terminal) => terminal, + Err(error) => { + log::error!("failed to spawn agent panel terminal: {error:#}"); + workspace + .update(cx, |workspace, cx| workspace.show_error(&error, cx)) + .log_err(); + return anyhow::Ok(()); + } + }; + this.update_in(cx, |this, window, cx| { + let terminal_view = cx.new(|cx| { + TerminalView::new(terminal, workspace, workspace_id, project, window, cx) + }); + this.insert_terminal(terminal_id, terminal_view, focus, window, cx); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn insert_terminal( + &mut self, + terminal_id: TerminalId, + terminal_view: Entity, + focus: bool, + window: &mut Window, + cx: &mut Context, + ) { + if !cx.has_flag::() { + return; + } + let terminal_entity = terminal_view.read(cx).terminal().clone(); + let title = { + let terminal_view = terminal_view.read(cx); + terminal_view + .custom_title() + .map(ToString::to_string) + .unwrap_or_else(|| terminal_view.terminal().read(cx).title(true)) + }; + let title_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_text(title, window, cx); + editor + }); + let title_editor_subscription = cx.subscribe_in( + &title_editor, + window, + move |this, title_editor, event: &editor::EditorEvent, window, cx| { + this.handle_terminal_title_editor_event( + terminal_id, + title_editor, + event, + window, + cx, + ); + }, + ); + let view_subscription = cx.subscribe_in( + &terminal_view, + window, + move |this, _terminal_view, event: &ItemEvent, window, cx| match event { + ItemEvent::UpdateTab | ItemEvent::UpdateBreadcrumbs => { + this.refresh_terminal_title(terminal_id, window, cx); + } + ItemEvent::CloseItem | ItemEvent::Edit => {} + }, + ); + // Listen on the underlying `Terminal` entity for shell-driven metadata + // changes and bell. + let terminal_subscription = cx.subscribe_in( + &terminal_entity, + window, + move |this, _terminal, event: &TerminalEvent, window, cx| match event { + TerminalEvent::TitleChanged + | TerminalEvent::Wakeup + | TerminalEvent::BreadcrumbsChanged => { + this.refresh_terminal_title(terminal_id, window, cx); + } + TerminalEvent::Bell => this.mark_terminal_notification(terminal_id, window, cx), + TerminalEvent::CloseTerminal => { + this.close_terminal(terminal_id, window, cx); + } + TerminalEvent::BlinkChanged(_) + | TerminalEvent::SelectionsChanged + | TerminalEvent::NewNavigationTarget(_) + | TerminalEvent::Open(_) => {} + }, + ); + + let mut terminal = AgentTerminal { + view: terminal_view, + title_editor, + last_known_title: String::new(), + created_at: Utc::now(), + has_notification: false, + _subscriptions: vec![ + view_subscription, + terminal_subscription, + title_editor_subscription, + ], + }; + self.set_last_created_entry_kind(AgentPanelEntryKind::Terminal, cx); + terminal.refresh_title(window, cx); + self.terminals.insert(terminal_id, terminal); + if focus { + self.set_base_view(BaseView::Terminal { terminal_id }, true, window, cx); + } + cx.emit(AgentPanelEvent::EntryChanged); + cx.notify(); + } + + pub fn activate_terminal( + &mut self, + terminal_id: TerminalId, + focus: bool, + window: &mut Window, + cx: &mut Context, + ) { + if !cx.has_flag::() { + return; + } + let Some(terminal) = self.terminals.get_mut(&terminal_id) else { + return; + }; + let had_notification = terminal.has_notification; + terminal.has_notification = false; + self.set_base_view(BaseView::Terminal { terminal_id }, focus, window, cx); + if had_notification { + cx.emit(AgentPanelEvent::EntryChanged); + cx.notify(); + } + } + + pub fn close_terminal( + &mut self, + terminal_id: TerminalId, + window: &mut Window, + cx: &mut Context, + ) { + let was_active = self.active_terminal_id() == Some(terminal_id); + + if self.terminals.remove(&terminal_id).is_none() { + return; + } + if was_active { + self.base_view = BaseView::Uninitialized; + self.refresh_base_view_subscriptions(window, cx); + self.activate_draft(false, "agent_panel", window, cx); + } + + cx.emit(AgentPanelEvent::EntryChanged); + cx.notify(); + } + + fn refresh_terminal_title( + &mut self, + terminal_id: TerminalId, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(terminal) = self.terminals.get_mut(&terminal_id) + && terminal.refresh_title(window, cx) + { + cx.emit(AgentPanelEvent::EntryChanged); + cx.notify(); + } + } + + fn handle_terminal_title_editor_event( + &mut self, + terminal_id: TerminalId, + title_editor: &Entity, + event: &editor::EditorEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + editor::EditorEvent::BufferEdited => { + if !title_editor.read(cx).is_focused(window) { + return; + } + let Some(terminal_view) = self + .terminals + .get(&terminal_id) + .map(|terminal| terminal.view.clone()) + else { + return; + }; + let new_title = title_editor.read(cx).text(cx).trim().to_string(); + let label = if new_title.is_empty() { + None + } else { + let terminal_title = terminal_view.read(cx).terminal().read(cx).title(true); + if new_title == terminal_title { + None + } else { + Some(new_title) + } + }; + + cx.defer(move |cx| { + terminal_view.update(cx, |terminal_view, cx| { + terminal_view.set_custom_title(label, cx); + }); + }); + } + editor::EditorEvent::Blurred => { + if let Some(terminal) = self.terminals.get_mut(&terminal_id) { + terminal.refresh_title(window, cx); + } + } + _ => {} + } + } + + fn mark_terminal_notification( + &mut self, + terminal_id: TerminalId, + window: &mut Window, + cx: &mut Context, + ) { + let is_active = self.active_terminal_id() == Some(terminal_id); + // Only suppress when the user can actually see the bell, i.e. the + // terminal is focused AND the OS window is active. A bell delivered to + // a background window should still be marked unseen. + let user_is_looking = is_active + && window.is_window_active() + && self.terminals.get(&terminal_id).is_some_and(|terminal| { + terminal.view.focus_handle(cx).contains_focused(window, cx) + }); + if user_is_looking { + return; + } + let Some(terminal) = self.terminals.get_mut(&terminal_id) else { + return; + }; + if !terminal.has_notification { + terminal.has_notification = true; + cx.emit(AgentPanelEvent::EntryChanged); + cx.notify(); + } + } + + fn default_terminal_working_directory(&self, cx: &App) -> Option { + // Reuse the workspace-based helper so behavior matches the regular + // terminal panel (e.g. `WorkingDirectory::FirstProjectDirectory` falling + // back to a file's parent directory when the worktree root is a file). + self.workspace + .upgrade() + .and_then(|workspace| terminal_view::default_working_directory(workspace.read(cx), cx)) } pub fn activate_draft( @@ -1287,6 +1755,33 @@ impl AgentPanel { } } + pub fn active_terminal_id(&self) -> Option { + match &self.base_view { + BaseView::Terminal { terminal_id } => Some(*terminal_id), + _ => None, + } + } + + pub fn has_terminal(&self, terminal_id: TerminalId) -> bool { + self.terminals.contains_key(&terminal_id) + } + + pub fn terminals(&self, cx: &App) -> Vec { + if !cx.has_flag::() { + return Vec::new(); + } + + self.terminals + .iter() + .map(|(id, terminal)| AgentPanelTerminalInfo { + id: *id, + title: terminal.display_title(cx), + created_at: terminal.created_at, + has_notification: terminal.has_notification, + }) + .collect() + } + pub fn editor_text(&self, id: ThreadId, cx: &App) -> Option { let cv = self .retained_threads @@ -1844,7 +2339,7 @@ impl AgentPanel { }); } - self.new_thread(&NewThread, window, cx); + self.activate_new_thread(true, "agent_panel", window, cx); if let Some((thread, model)) = self .active_native_agent_thread(cx) .zip(provider.default_model(cx)) @@ -2022,7 +2517,8 @@ impl AgentPanel { self.retain_running_thread(old_view, cx); if let BaseView::AgentThread { conversation_view } = &self.base_view { - let thread_agent = conversation_view.read(cx).agent_key().clone(); + let conversation_view = conversation_view.read(cx); + let thread_agent = conversation_view.agent_key().clone(); if self.selected_agent != thread_agent { self.selected_agent = thread_agent; self.serialize(cx); @@ -2074,7 +2570,7 @@ impl AgentPanel { let focus_handle = conversation_view.focus_handle(cx); self._active_thread_focus_subscription = Some(cx.on_focus_in(&focus_handle, window, |_this, _window, cx| { - cx.emit(AgentPanelEvent::ThreadFocused); + cx.emit(AgentPanelEvent::ActiveViewFocused); cx.notify(); })); Some(cx.observe_in( @@ -2089,6 +2585,26 @@ impl AgentPanel { }, )) } + BaseView::Terminal { terminal_id } => { + self._thread_view_subscription = None; + if let Some(terminal) = self.terminals.get(terminal_id) { + let terminal_id = *terminal_id; + let focus_handle = terminal.view.focus_handle(cx); + self._active_thread_focus_subscription = + Some( + cx.on_focus_in(&focus_handle, window, move |this, _window, cx| { + if let Some(terminal) = this.terminals.get_mut(&terminal_id) { + terminal.has_notification = false; + } + cx.emit(AgentPanelEvent::ActiveViewFocused); + cx.notify(); + }), + ); + } else { + self._active_thread_focus_subscription = None; + } + None + } BaseView::Uninitialized => { self._thread_view_subscription = None; self._active_thread_focus_subscription = None; @@ -2112,6 +2628,11 @@ impl AgentPanel { BaseView::AgentThread { conversation_view } => { VisibleSurface::AgentThread(conversation_view) } + BaseView::Terminal { terminal_id } => self + .terminals + .get(terminal_id) + .map(|terminal| VisibleSurface::Terminal(&terminal.view)) + .unwrap_or(VisibleSurface::Uninitialized), } } @@ -2368,7 +2889,7 @@ impl AgentPanel { cx.emit(AgentPanelEvent::ActiveViewChanged); this.serialize(cx); } else { - cx.emit(AgentPanelEvent::RetainedThreadChanged); + cx.emit(AgentPanelEvent::EntryChanged); } cx.notify(); }) @@ -2395,6 +2916,7 @@ impl Focusable for AgentPanel { match self.visible_surface() { VisibleSurface::Uninitialized => self.focus_handle.clone(), VisibleSurface::AgentThread(conversation_view) => conversation_view.focus_handle(cx), + VisibleSurface::Terminal(terminal_view) => terminal_view.focus_handle(cx), VisibleSurface::Configuration(configuration) => { if let Some(configuration) = configuration { configuration.focus_handle(cx) @@ -2412,8 +2934,8 @@ fn agent_panel_dock_position(cx: &App) -> DockPosition { pub enum AgentPanelEvent { ActiveViewChanged, - ThreadFocused, - RetainedThreadChanged, + ActiveViewFocused, + EntryChanged, ThreadInteracted { thread_id: ThreadId }, } @@ -2535,12 +3057,16 @@ impl AgentPanel { } fn destination_has_meaningful_state(&self, cx: &App) -> bool { - if self.overlay_view.is_some() || !self.retained_threads.is_empty() { + if self.overlay_view.is_some() + || !self.retained_threads.is_empty() + || !self.terminals.is_empty() + { return true; } match &self.base_view { BaseView::Uninitialized => false, + BaseView::Terminal { .. } => true, BaseView::AgentThread { conversation_view } => { let has_entries = conversation_view .read(cx) @@ -2725,6 +3251,30 @@ impl AgentPanel { .into_any_element() } } + VisibleSurface::Terminal(_) => { + if let Some((title_editor, terminal_view)) = self + .active_terminal_id() + .and_then(|terminal_id| self.terminals.get(&terminal_id)) + .map(|terminal| (terminal.title_editor.clone(), terminal.view.clone())) + { + let terminal_view_cancel = terminal_view.clone(); + div() + .flex_1() + .on_action(move |_: &menu::Confirm, window, cx| { + terminal_view.focus_handle(cx).focus(window, cx); + }) + .on_action(move |_: &editor::actions::Cancel, window, cx| { + terminal_view_cancel.focus_handle(cx).focus(window, cx); + }) + .child(title_editor) + .into_any_element() + } else { + Label::new("Terminal") + .color(Color::Muted) + .truncate() + .into_any_element() + } + } VisibleSurface::Configuration(_) => { Label::new("Settings").truncate().into_any_element() } @@ -2870,25 +3420,28 @@ impl AgentPanel { let agent_server_store = self.project.read(cx).agent_server_store().clone(); let focus_handle = self.focus_handle(cx); - - let (selected_agent_custom_icon, selected_agent_label) = - if let Agent::Custom { id, .. } = &self.selected_agent { - let store = agent_server_store.read(cx); - let icon = store.agent_icon(&id); - - let label = store - .agent_display_name(&id) - .unwrap_or_else(|| self.selected_agent.label()); - (icon, label) - } else { - (None, self.selected_agent.label()) - }; + let supports_terminal = self.supports_terminal(cx); + + let showing_terminal = matches!(self.visible_surface(), VisibleSurface::Terminal(_)); + let (selected_agent_custom_icon, selected_agent_label) = if showing_terminal { + (None, SharedString::from("Terminal")) + } else if let Agent::Custom { id, .. } = &self.selected_agent { + let store = agent_server_store.read(cx); + let icon = store.agent_icon(&id); + + let label = store + .agent_display_name(&id) + .unwrap_or_else(|| self.selected_agent.label()); + (icon, label) + } else { + (None, self.selected_agent.label()) + }; let active_thread = match &self.base_view { BaseView::AgentThread { conversation_view } => { conversation_view.read(cx).as_native_thread(cx) } - BaseView::Uninitialized => None, + BaseView::Terminal { .. } | BaseView::Uninitialized => None, }; let new_thread_menu_builder: Rc< @@ -2963,6 +3516,33 @@ impl AgentPanel { } }), ) + .when(supports_terminal, |menu| { + menu.item( + ContextMenuEntry::new("Terminal") + .icon(IconName::Terminal) + .icon_color(Color::Muted) + .handler({ + let workspace = workspace.clone(); + move |window, cx| { + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + if let Some(panel) = + workspace.panel::(cx) + { + panel.update(cx, |panel, cx| { + panel.new_terminal( + Some(workspace), + window, + cx, + ); + }); + } + }); + } + } + }), + ) + }) .map(|mut menu| { let agent_server_store = agent_server_store.read(cx); let registry_store = project::AgentRegistryStore::try_global(cx); @@ -3080,7 +3660,11 @@ impl AgentPanel { let has_custom_icon = selected_agent_custom_icon.is_some(); let selected_agent_custom_icon_for_button = selected_agent_custom_icon.clone(); - let selected_agent_builtin_icon = self.selected_agent.icon(); + let selected_agent_builtin_icon = if showing_terminal { + Some(IconName::Terminal) + } else { + self.selected_agent.icon() + }; let selected_agent_label_for_tooltip = selected_agent_label.clone(); let selected_agent = div() @@ -3120,7 +3704,8 @@ impl AgentPanel { selected_agent.into_any_element() }; - let is_empty_state = !self.active_thread_has_messages(cx); + let is_empty_state = !matches!(self.base_view, BaseView::Terminal { .. }) + && !self.active_thread_has_messages(cx); let is_in_history_or_config = self.is_overlay_open(); @@ -3296,7 +3881,7 @@ impl AgentPanel { return false; } } - BaseView::Uninitialized => { + BaseView::Terminal { .. } | BaseView::Uninitialized => { return false; } } @@ -3348,7 +3933,7 @@ impl AgentPanel { }); match &self.base_view { - BaseView::Uninitialized => false, + BaseView::Uninitialized | BaseView::Terminal { .. } => false, BaseView::AgentThread { conversation_view } => { if conversation_view.read(cx).as_native_thread(cx).is_some() { let history_is_empty = ThreadStore::global(cx).read(cx).is_empty(); @@ -3477,16 +4062,18 @@ impl AgentPanel { conversation_view.insert_dragged_files(paths, added_worktrees, window, cx); }); } - BaseView::Uninitialized => {} + BaseView::Terminal { .. } | BaseView::Uninitialized => {} } } fn key_context(&self) -> KeyContext { let mut key_context = KeyContext::new_with_defaults(); key_context.add("AgentPanel"); - match &self.base_view { - BaseView::AgentThread { .. } => key_context.add("acp_thread"), - BaseView::Uninitialized => {} + match self.visible_surface() { + VisibleSurface::AgentThread(_) => key_context.add("acp_thread"), + VisibleSurface::Terminal(_) + | VisibleSurface::Configuration(_) + | VisibleSurface::Uninitialized => {} } key_context } @@ -3536,6 +4123,7 @@ impl Render for AgentPanel { VisibleSurface::AgentThread(conversation_view) => parent .child(conversation_view.clone()) .child(self.render_drag_target(cx)), + VisibleSurface::Terminal(terminal_view) => parent.child(terminal_view.clone()), VisibleSurface::Configuration(configuration) => { parent.children(configuration.cloned()) } @@ -3715,6 +4303,61 @@ impl AgentPanel { self.draft_thread = Some(thread.conversation_view.clone()); self.set_base_view(thread.into(), true, window, cx); } + + #[cfg(any(test, feature = "test-support"))] + pub fn insert_test_terminal( + &mut self, + title: impl Into, + focus: bool, + window: &mut Window, + cx: &mut Context, + ) -> Result { + if !cx.has_flag::() { + anyhow::bail!("agent-panel-terminal feature flag must be enabled"); + } + + let terminal_id = TerminalId::new(); + let settings = TerminalSettings::get_global(cx).clone(); + let path_style = self.project.read(cx).path_style(cx); + let builder = terminal::TerminalBuilder::new_display_only( + settings.cursor_shape, + settings.alternate_scroll, + settings.max_scroll_history_lines, + cx.entity_id().as_u64(), + cx.background_executor(), + path_style, + )?; + let terminal = cx.new(|cx| builder.subscribe(cx)); + let terminal_view = cx.new(|cx| { + TerminalView::new( + terminal, + self.workspace.clone(), + self.workspace_id, + self.project.downgrade(), + window, + cx, + ) + }); + terminal_view.update(cx, |terminal_view, cx| { + terminal_view.set_custom_title(Some(title.into()), cx); + }); + self.insert_terminal(terminal_id, terminal_view, focus, window, cx); + Ok(terminal_id) + } + + #[cfg(any(test, feature = "test-support"))] + pub fn emit_test_terminal_bell(&mut self, terminal_id: TerminalId, cx: &mut Context) { + let Some(terminal_entity) = self + .terminals + .get(&terminal_id) + .map(|terminal| terminal.view.read(cx).terminal().clone()) + else { + return; + }; + terminal_entity.update(cx, |_terminal, cx| { + cx.emit(TerminalEvent::Bell); + }); + } } #[cfg(test)] @@ -4689,6 +5332,7 @@ mod tests { }); let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); let project = Project::test(fs.clone(), [], cx).await; let multi_workspace = @@ -4707,6 +5351,98 @@ mod tests { (panel, cx) } + #[gpui::test] + async fn test_terminal_entry_kind_controls_new_entry(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + cx.update(|_, cx| { + cx.update_flags(true, vec!["agent-panel-terminal".to_string()]); + }); + + panel.read_with(&cx, |panel, cx| { + assert!(panel.supports_terminal(cx)); + assert!(!panel.should_create_terminal_for_new_entry(cx)); + }); + + let terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, cx| { + assert_eq!(panel.active_terminal_id(), Some(terminal_id)); + assert!(panel.has_terminal(terminal_id)); + assert!(panel.should_create_terminal_for_new_entry(cx)); + let terminals = panel.terminals(cx); + assert_eq!(terminals.len(), 1); + assert_eq!(terminals[0].title.as_ref(), "Dev Server"); + }); + + panel.update_in(&mut cx, |panel, window, cx| { + panel.activate_new_thread(false, "test", window, cx); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, cx| { + assert_eq!(panel.active_terminal_id(), None); + assert!(panel.has_terminal(terminal_id)); + assert!(!panel.should_create_terminal_for_new_entry(cx)); + }); + } + + #[gpui::test] + async fn test_terminal_bell_marks_and_activation_clears_notification(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + cx.update(|_, cx| { + cx.update_flags(true, vec!["agent-panel-terminal".to_string()]); + }); + + let first_terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Build", true, window, cx) + }) + .expect("first test terminal should be inserted"); + let second_terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Server", true, window, cx) + }) + .expect("second test terminal should be inserted"); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, _cx| { + assert_eq!(panel.active_terminal_id(), Some(second_terminal_id)); + }); + + panel.update(&mut cx, |panel, cx| { + panel.emit_test_terminal_bell(first_terminal_id, cx); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, cx| { + let first_terminal = panel + .terminals(cx) + .into_iter() + .find(|terminal| terminal.id == first_terminal_id) + .expect("first terminal should remain in the panel"); + assert!(first_terminal.has_notification); + }); + + panel.update_in(&mut cx, |panel, window, cx| { + panel.activate_terminal(first_terminal_id, true, window, cx); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, cx| { + let first_terminal = panel + .terminals(cx) + .into_iter() + .find(|terminal| terminal.id == first_terminal_id) + .expect("first terminal should remain in the panel"); + assert!(!first_terminal.has_notification); + }); + } + #[gpui::test] async fn test_running_thread_retained_when_navigating_away(cx: &mut TestAppContext) { let (panel, mut cx) = setup_panel(cx).await; diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 226471fc024294..758622411935a9 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -61,7 +61,9 @@ use workspace::Workspace; use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal}; pub use crate::agent_connection_store::{ActiveAcpConnection, AgentConnectionStore}; -pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, MaxIdleRetainedThreads}; +pub use crate::agent_panel::{ + AgentPanel, AgentPanelEvent, AgentPanelTerminalInfo, MaxIdleRetainedThreads, TerminalId, +}; use crate::agent_registry_ui::AgentRegistryPage; pub use crate::inline_assistant::InlineAssistant; pub use crate::thread_metadata_store::ThreadId; diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 00cc74a9b87db4..773507e2af1d59 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -2708,10 +2708,10 @@ impl ConversationView { &panel, window, move |this, _, event: &AgentPanelEvent, window, cx| match event { - AgentPanelEvent::ActiveViewChanged | AgentPanelEvent::ThreadFocused => { + AgentPanelEvent::ActiveViewChanged | AgentPanelEvent::ActiveViewFocused => { dismiss_if_visible(this, window, cx); } - AgentPanelEvent::RetainedThreadChanged + AgentPanelEvent::EntryChanged | AgentPanelEvent::ThreadInteracted { .. } => {} }, )); diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index d9af542efeabec..cb216267376257 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -35,6 +35,18 @@ impl FeatureFlag for AgentSharingFeatureFlag { } register_feature_flag!(AgentSharingFeatureFlag); +pub struct AgentPanelTerminalFeatureFlag; + +impl FeatureFlag for AgentPanelTerminalFeatureFlag { + const NAME: &'static str = "agent-panel-terminal"; + type Value = PresenceFlag; + + fn enabled_for_staff() -> bool { + false + } +} +register_feature_flag!(AgentPanelTerminalFeatureFlag); + pub struct DiffReviewFeatureFlag; impl FeatureFlag for DiffReviewFeatureFlag { diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index ac34cbdd0610c2..dde7d4f1b3907f 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -2264,14 +2264,7 @@ impl Project { #[inline] pub fn supports_terminal(&self, _cx: &App) -> bool { - if self.is_local() { - return true; - } - if self.is_via_remote_server() { - return true; - } - - false + self.is_local() || self.is_via_remote_server() } #[inline] diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index be525a5c6e5802..f9ae2ed5241d96 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -29,6 +29,7 @@ feature_flags.workspace = true fs.workspace = true git.workspace = true gpui.workspace = true +itertools.workspace = true log.workspace = true menu.workspace = true platform_title_bar.workspace = true diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 0000aac3f36026..a25fbac1513ae2 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -12,20 +12,23 @@ use agent_ui::threads_archive_view::{ ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp, }; use agent_ui::{ - AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, ArchiveSelectedThread, - CrossChannelImportOnboarding, DEFAULT_THREAD_TITLE, NewThread, ThreadId, ThreadImportModal, - channels_with_threads, import_threads_from_other_channels, + AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, AgentPanelTerminalInfo, + ArchiveSelectedThread, CrossChannelImportOnboarding, DEFAULT_THREAD_TITLE, NewThread, + TerminalId, ThreadId, ThreadImportModal, channels_with_threads, + import_threads_from_other_channels, }; use chrono::{DateTime, Utc}; use editor::Editor; use feature_flags::{ - AgentThreadWorktreeLabel, AgentThreadWorktreeLabelFlag, FeatureFlag, FeatureFlagAppExt as _, + AgentPanelTerminalFeatureFlag, AgentThreadWorktreeLabel, AgentThreadWorktreeLabelFlag, + FeatureFlag, FeatureFlagAppExt as _, FeatureFlagViewExt as _, }; use gpui::{ Action as _, AnyElement, App, ClickEvent, Context, DismissEvent, Entity, EntityId, FocusHandle, Focusable, KeyContext, ListState, Modifiers, Pixels, Render, SharedString, Task, TaskExt, WeakEntity, Window, WindowHandle, linear_color_stop, linear_gradient, list, prelude::*, px, }; +use itertools::Itertools; use menu::{ Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious, }; @@ -118,34 +121,57 @@ enum ArchiveWorktreeOutcome { } #[derive(Clone, Debug)] -struct ActiveEntry { - thread_id: agent_ui::ThreadId, - /// Stable remote identifier, used for matching when thread_id - /// differs (e.g. after cross-window activation creates a new - /// local ThreadId). - session_id: Option, - workspace: Entity, +enum ActiveEntry { + Thread { + thread_id: agent_ui::ThreadId, + /// Stable remote identifier, used for matching when thread_id + /// differs (e.g. after cross-window activation creates a new + /// local ThreadId). + session_id: Option, + workspace: Entity, + }, + Terminal { + terminal_id: TerminalId, + workspace: Entity, + }, } impl ActiveEntry { fn workspace(&self) -> &Entity { - &self.workspace + match self { + ActiveEntry::Thread { workspace, .. } | ActiveEntry::Terminal { workspace, .. } => { + workspace + } + } } fn is_active_thread(&self, thread_id: &agent_ui::ThreadId) -> bool { - self.thread_id == *thread_id + matches!(self, ActiveEntry::Thread { thread_id: active_thread_id, .. } if active_thread_id == thread_id) + } + + fn is_active_terminal(&self, terminal_id: TerminalId) -> bool { + matches!(self, ActiveEntry::Terminal { terminal_id: active_terminal_id, .. } if *active_terminal_id == terminal_id) } fn matches_entry(&self, entry: &ListEntry) -> bool { - match entry { - ListEntry::Thread(thread) => { - self.thread_id == thread.metadata.thread_id - || self - .session_id + match (self, entry) { + ( + ActiveEntry::Thread { + thread_id, + session_id, + .. + }, + ListEntry::Thread(thread), + ) => { + *thread_id == thread.metadata.thread_id + || session_id .as_ref() .zip(thread.metadata.session_id.as_ref()) .is_some_and(|(a, b)| a == b) } + (ActiveEntry::Terminal { terminal_id, .. }, ListEntry::Terminal(terminal)) => { + *terminal_id == terminal.id + } _ => false, } } @@ -202,6 +228,16 @@ struct ThreadEntry { diff_stats: DiffStats, } +#[derive(Clone)] +struct TerminalEntry { + id: TerminalId, + title: SharedString, + workspace: Entity, + created_at: DateTime, + has_notification: bool, + highlight_positions: Vec, +} + impl ThreadEntry { /// Updates this thread entry with active thread information. /// @@ -232,6 +268,59 @@ enum ListEntry { has_threads: bool, }, Thread(ThreadEntry), + Terminal(TerminalEntry), +} + +#[derive(Clone)] +enum ActivatableEntry { + Thread { + metadata: ThreadMetadata, + workspace: ThreadEntryWorkspace, + }, + Terminal { + terminal_id: TerminalId, + workspace: Entity, + }, +} + +impl ActivatableEntry { + fn from_list_entry(entry: &ListEntry) -> Option { + match entry { + ListEntry::Thread(thread) => Some(Self::Thread { + metadata: thread.metadata.clone(), + workspace: thread.workspace.clone(), + }), + ListEntry::Terminal(terminal) => Some(Self::Terminal { + terminal_id: terminal.id, + workspace: terminal.workspace.clone(), + }), + ListEntry::ProjectHeader { .. } => None, + } + } + + fn project_location(&self, cx: &App) -> (PathList, ProjectGroupKey) { + match self { + Self::Thread { + workspace: ThreadEntryWorkspace::Open(workspace), + .. + } => ( + PathList::new(&workspace.read(cx).root_paths(cx)), + workspace.read(cx).project_group_key(cx), + ), + Self::Thread { + workspace: + ThreadEntryWorkspace::Closed { + folder_paths, + project_group_key, + }, + .. + } => (folder_paths.clone(), project_group_key.clone()), + Self::Terminal { workspace, .. } => ( + PathList::new(&workspace.read(cx).root_paths(cx)), + workspace.read(cx).project_group_key(cx), + ), + } + } } #[cfg(test)] @@ -239,7 +328,7 @@ impl ListEntry { fn session_id(&self) -> Option<&acp::SessionId> { match self { ListEntry::Thread(thread_entry) => thread_entry.metadata.session_id.as_ref(), - _ => None, + ListEntry::Terminal(_) | ListEntry::ProjectHeader { .. } => None, } } @@ -253,6 +342,7 @@ impl ListEntry { ThreadEntryWorkspace::Open(ws) => vec![ws.clone()], ThreadEntryWorkspace::Closed { .. } => Vec::new(), }, + ListEntry::Terminal(terminal) => vec![terminal.workspace.clone()], ListEntry::ProjectHeader { key, .. } => multi_workspace .workspaces_for_project_group(key, cx) .unwrap_or_default(), @@ -266,10 +356,17 @@ impl From for ListEntry { } } +impl From for ListEntry { + fn from(terminal: TerminalEntry) -> Self { + ListEntry::Terminal(terminal) + } +} + #[derive(Default)] struct SidebarContents { entries: Vec, notified_threads: HashSet, + notified_terminals: HashSet, project_header_indices: Vec, has_open_projects: bool, } @@ -328,6 +425,25 @@ fn workspace_path_list(workspace: &Entity, cx: &App) -> PathList { PathList::new(&workspace.read(cx).root_paths(cx)) } +fn workspace_has_agent_panel_terminals(workspace: &Entity, cx: &App) -> bool { + workspace + .read(cx) + .panel::(cx) + .is_some_and(|panel| !panel.read(cx).terminals(cx).is_empty()) +} + +fn workspace_contains_worktree_path( + workspace: &Entity, + worktree_path: &Path, + cx: &App, +) -> bool { + let project = workspace.read(cx).project().clone(); + project + .read(cx) + .visible_worktrees(cx) + .any(|worktree| worktree.read(cx).abs_path().as_ref() == worktree_path) +} + #[derive(Clone)] struct WorkspaceMenuWorktreeLabel { icon: Option, @@ -505,6 +621,17 @@ impl Sidebar { .detach(); AgentThreadWorktreeLabelFlag::watch(cx); + cx.observe_flag::( + window, + |enabled, this, _window, cx| { + if !*enabled && matches!(this.active_entry, Some(ActiveEntry::Terminal { .. })) { + this.active_entry = None; + } + this.sync_active_entry_from_active_workspace(cx); + this.update_entries(cx); + }, + ) + .detach(); let filter_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); @@ -749,13 +876,11 @@ impl Sidebar { cx.subscribe_in( agent_panel, window, - |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event { - AgentPanelEvent::ActiveViewChanged => { - this.sync_active_entry_from_panel(_agent_panel, cx); - this.update_entries(cx); - } - AgentPanelEvent::ThreadFocused | AgentPanelEvent::RetainedThreadChanged => { - this.sync_active_entry_from_panel(_agent_panel, cx); + |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event { + AgentPanelEvent::ActiveViewChanged + | AgentPanelEvent::ActiveViewFocused + | AgentPanelEvent::EntryChanged => { + this.sync_active_entry_from_panel(agent_panel, cx); this.update_entries(cx); } AgentPanelEvent::ThreadInteracted { thread_id } => { @@ -830,7 +955,7 @@ impl Sidebar { let session_id = panel .active_agent_thread(cx) .map(|thread| thread.read(cx).session_id().clone()); - self.active_entry = Some(ActiveEntry { + self.active_entry = Some(ActiveEntry::Thread { thread_id: pending_thread_id, session_id, workspace: active_workspace, @@ -842,7 +967,14 @@ impl Sidebar { return false; } - if let Some(thread_id) = panel.active_thread_id(cx) { + if cx.has_flag::() + && let Some(terminal_id) = panel.active_terminal_id() + { + self.active_entry = Some(ActiveEntry::Terminal { + terminal_id, + workspace: active_workspace, + }); + } else if let Some(thread_id) = panel.active_thread_id(cx) { let is_archived = ThreadMetadataStore::global(cx) .read(cx) .entry(thread_id) @@ -851,7 +983,7 @@ impl Sidebar { let session_id = panel .active_agent_thread(cx) .map(|thread| thread.read(cx).session_id().clone()); - self.active_entry = Some(ActiveEntry { + self.active_entry = Some(ActiveEntry::Thread { thread_id, session_id, workspace: active_workspace, @@ -925,7 +1057,7 @@ impl Sidebar { .detach_and_log_err(cx); } - fn open_workspace_and_create_draft( + fn open_workspace_and_create_entry( &mut self, project_group_key: &ProjectGroupKey, window: &mut Window, @@ -957,7 +1089,7 @@ impl Sidebar { cx.spawn_in(window, async move |this, cx| { let workspace = task.await?; this.update_in(cx, |this, window, cx| { - this.create_new_thread(&workspace, window, cx); + this.create_new_entry(&workspace, window, cx); })?; anyhow::Ok(()) }) @@ -1009,6 +1141,7 @@ impl Sidebar { let mut entries = Vec::new(); let mut notified_threads = previous.notified_threads; + let mut notified_terminals: HashSet = HashSet::new(); let mut current_session_ids: HashSet = HashSet::new(); let mut current_thread_ids: HashSet = HashSet::new(); let mut project_header_indices: Vec = Vec::new(); @@ -1072,6 +1205,15 @@ impl Sidebar { for group in &groups { let group_key = &group.key; let group_workspaces = &group.workspaces; + let terminals: Vec = group_workspaces + .iter() + .flat_map(|workspace| terminal_entries_for_workspace(workspace, cx)) + .collect(); + notified_terminals.extend( + terminals + .iter() + .filter_map(|terminal| terminal.has_notification.then_some(terminal.id)), + ); if group_key.path_list().paths().is_empty() { continue; } @@ -1297,7 +1439,7 @@ impl Sidebar { } } - let has_threads = if !threads.is_empty() { + let has_threads = if !threads.is_empty() || !terminals.is_empty() { true } else { let store = ThreadMetadataStore::global(cx).read(cx); @@ -1344,7 +1486,20 @@ impl Sidebar { } } - if matched_threads.is_empty() && !workspace_matched { + let mut matched_terminals: Vec = Vec::new(); + for mut terminal in terminals { + let mut terminal_matched = false; + if let Some(positions) = fuzzy_match_positions(&query, &terminal.title) { + terminal.highlight_positions = positions; + terminal_matched = true; + } + if workspace_matched || terminal_matched { + matched_terminals.push(terminal); + } + } + + if matched_threads.is_empty() && matched_terminals.is_empty() && !workspace_matched + { continue; } @@ -1359,13 +1514,13 @@ impl Sidebar { has_threads, }); - for thread in matched_threads { - if let Some(sid) = thread.metadata.session_id.clone() { - current_session_ids.insert(sid); - } - current_thread_ids.insert(thread.metadata.thread_id); - entries.push(thread.into()); - } + Self::push_entries_by_display_time( + &mut entries, + matched_terminals, + matched_threads, + &mut current_session_ids, + &mut current_thread_ids, + ); } else { project_header_indices.push(entries.len()); entries.push(ListEntry::ProjectHeader { @@ -1382,13 +1537,13 @@ impl Sidebar { continue; } - for thread in threads { - if let Some(sid) = &thread.metadata.session_id { - current_session_ids.insert(sid.clone()); - } - current_thread_ids.insert(thread.metadata.thread_id); - entries.push(thread.into()); - } + Self::push_entries_by_display_time( + &mut entries, + terminals, + threads, + &mut current_session_ids, + &mut current_thread_ids, + ); } } @@ -1400,6 +1555,7 @@ impl Sidebar { self.contents = SidebarContents { entries, notified_threads, + notified_terminals, project_header_indices, has_open_projects, }; @@ -1436,7 +1592,7 @@ impl Sidebar { .contents .entries .iter() - .position(|entry| matches!(entry, ListEntry::Thread(_))) + .position(|entry| matches!(entry, ListEntry::Thread(_) | ListEntry::Terminal(_))) .or_else(|| { if self.contents.entries.is_empty() { None @@ -1483,8 +1639,10 @@ impl Sidebar { .and_then(|ws| ws.read(cx).panel::(cx)) .is_some_and(|panel| { let panel = panel.read(cx); - panel.active_thread_is_draft(cx) - || panel.active_conversation_view().is_none() + // An active terminal is its own surface, not a draft. + panel.active_terminal_id().is_none() + && (panel.active_thread_is_draft(cx) + || panel.active_conversation_view().is_none()) }); self.project_header_menu_handles.entry(ix).or_default(); self.render_project_header( @@ -1503,6 +1661,9 @@ impl Sidebar { ) } ListEntry::Thread(thread) => self.render_thread(ix, thread, is_active, is_selected, cx), + ListEntry::Terminal(terminal) => { + self.render_terminal(ix, terminal, is_active, is_selected, cx) + } }; if is_group_header_after_first { @@ -1705,9 +1866,9 @@ impl Sidebar { this.set_group_expanded(&key, true, cx); this.selection = None; if let Some(workspace) = this.workspace_for_group(&key, cx) { - this.create_new_thread(&workspace, window, cx); + this.create_new_entry(&workspace, window, cx); } else { - this.open_workspace_and_create_draft(&key, window, cx); + this.open_workspace_and_create_entry(&key, window, cx); } }, )) @@ -2127,7 +2288,10 @@ impl Sidebar { .and_then(|ws| ws.read(cx).panel::(cx)) .is_some_and(|panel| { let panel = panel.read(cx); - panel.active_thread_is_draft(cx) || panel.active_conversation_view().is_none() + // An active terminal is its own surface, not a draft. + panel.active_terminal_id().is_none() + && (panel.active_thread_is_draft(cx) + || panel.active_conversation_view().is_none()) }); let header_element = self.render_project_header( header_idx, @@ -2391,6 +2555,10 @@ impl Sidebar { } } } + ListEntry::Terminal(terminal) => { + let workspace = terminal.workspace.clone(); + self.activate_terminal(&workspace, terminal.id, false, window, cx); + } } } @@ -2514,7 +2682,7 @@ impl Sidebar { // Set active_entry eagerly so the sidebar highlight updates // immediately, rather than waiting for a deferred AgentPanel // event which can race with ActiveWorkspaceChanged clearing it. - self.active_entry = Some(ActiveEntry { + self.active_entry = Some(ActiveEntry::Thread { thread_id: metadata.thread_id, session_id: metadata.session_id.clone(), workspace: workspace.clone(), @@ -2583,7 +2751,7 @@ impl Sidebar { { target_sidebar.update(cx, |sidebar, cx| { sidebar.pending_thread_activation = Some(metadata_thread_id); - sidebar.active_entry = Some(ActiveEntry { + sidebar.active_entry = Some(ActiveEntry::Thread { thread_id: metadata_thread_id, session_id: target_session_id.clone(), workspace: workspace_for_entry.clone(), @@ -2957,7 +3125,7 @@ impl Sidebar { self.update_entries(cx); } } - Some(ListEntry::Thread(_)) => { + Some(ListEntry::Thread(_) | ListEntry::Terminal(_)) => { for i in (0..ix).rev() { if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(i) { @@ -2984,7 +3152,7 @@ impl Sidebar { // Find the group header for the current selection. let header_ix = match self.contents.entries.get(ix) { Some(ListEntry::ProjectHeader { .. }) => Some(ix), - Some(ListEntry::Thread(_)) => (0..ix).rev().find(|&i| { + Some(ListEntry::Thread(_) | ListEntry::Terminal(_)) => (0..ix).rev().find(|&i| { matches!( self.contents.entries.get(i), Some(ListEntry::ProjectHeader { .. }) @@ -3053,6 +3221,148 @@ impl Sidebar { } } + /// Find the neighbor thread in the sidebar (by display position). + /// Look below first, then above, for the nearest thread that isn't + /// the one being archived. We capture both the neighbor's metadata + /// (for activation) and its workspace paths (for the workspace + /// removal fallback). + fn neighboring_activatable_entry(&self, current_position: usize) -> Option { + let after = self + .contents + .entries + .get(current_position.checked_add(1)?..)?; + let before = self.contents.entries.get(..current_position)?; + after + .iter() + .chain(before.iter().rev()) + .find_map(ActivatableEntry::from_list_entry) + } + + fn activate_entry( + &mut self, + entry: &ActivatableEntry, + window: &mut Window, + cx: &mut Context, + ) -> bool { + match entry { + ActivatableEntry::Thread { metadata, .. } => { + let Some(workspace) = self.multi_workspace.upgrade().and_then(|multi_workspace| { + multi_workspace + .read(cx) + .workspace_for_paths(metadata.folder_paths(), None, cx) + }) else { + return false; + }; + + self.active_entry = Some(ActiveEntry::Thread { + thread_id: metadata.thread_id, + session_id: metadata.session_id.clone(), + workspace: workspace.clone(), + }); + self.activate_workspace(&workspace, window, cx); + Self::load_agent_thread_in_workspace(&workspace, metadata, true, window, cx); + true + } + ActivatableEntry::Terminal { + terminal_id, + workspace, + } => { + if !cx.has_flag::() { + return false; + } + let Some(workspace) = self + .find_workspace_in_current_window(cx, |candidate, _| candidate == workspace) + else { + return false; + }; + self.activate_terminal(&workspace, *terminal_id, false, window, cx); + true + } + } + } + + fn activate_terminal( + &mut self, + workspace: &Entity, + terminal_id: TerminalId, + retain: bool, + window: &mut Window, + cx: &mut Context, + ) { + if !cx.has_flag::() { + return; + } + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + + self.active_entry = Some(ActiveEntry::Terminal { + terminal_id, + workspace: workspace.clone(), + }); + + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.activate(workspace.clone(), None, window, cx); + if retain { + multi_workspace.retain_active_workspace(cx); + } + }); + + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.activate_terminal(terminal_id, true, window, cx); + }); + } + workspace.focus_panel::(window, cx); + }); + + self.update_entries(cx); + } + + fn close_terminal( + &mut self, + workspace: &Entity, + terminal_id: TerminalId, + window: &mut Window, + cx: &mut Context, + ) { + let is_active = self + .active_entry + .as_ref() + .is_some_and(|entry| entry.is_active_terminal(terminal_id)); + let neighbor = self + .contents + .entries + .iter() + .position(|entry| matches!(entry, ListEntry::Terminal(terminal) if terminal.id == terminal_id)) + .and_then(|position| { + self.neighboring_activatable_entry(position) + }); + + // Closing from the sidebar must not steal focus, since the row's + // workspace may not be the active workspace. + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.close_terminal(terminal_id, window, cx); + }); + } + }); + + if is_active { + self.active_entry = None; + if neighbor + .as_ref() + .is_some_and(|neighbor| self.activate_entry(neighbor, window, cx)) + { + return; + } + self.sync_active_entry_from_active_workspace(cx); + } + self.update_entries(cx); + } + fn archive_thread( &mut self, session_id: &acp::SessionId, @@ -3064,7 +3374,7 @@ impl Sidebar { let active_workspace = metadata.as_ref().and_then(|metadata| { self.active_entry.as_ref().and_then(|entry| { if entry.is_active_thread(&metadata.thread_id) { - Some(entry.workspace.clone()) + Some(entry.workspace().clone()) } else { None } @@ -3126,15 +3436,20 @@ impl Sidebar { ) }) }) + .filter(|root| { + !workspaces.iter().any(|workspace| { + workspace_has_agent_panel_terminals(workspace, cx) + && workspace_contains_worktree_path( + workspace, + root.root_path.as_path(), + cx, + ) + }) + }) .collect::>() }) .unwrap_or_default(); - // Find the neighbor thread in the sidebar (by display position). - // Look below first, then above, for the nearest thread that isn't - // the one being archived. We capture both the neighbor's metadata - // (for activation) and its workspace paths (for the workspace - // removal fallback). let current_pos = self.contents.entries.iter().position(|entry| match entry { ListEntry::Thread(thread) => thread_id.map_or_else( || thread.metadata.session_id.as_ref() == Some(session_id), @@ -3142,27 +3457,8 @@ impl Sidebar { ), _ => false, }); - let neighbor = current_pos.and_then(|pos| { - self.contents.entries[pos + 1..] - .iter() - .chain(self.contents.entries[..pos].iter().rev()) - .find_map(|entry| match entry { - ListEntry::Thread(t) if t.metadata.session_id.as_ref() != Some(session_id) => { - let (workspace_paths, project_group_key) = match &t.workspace { - ThreadEntryWorkspace::Open(ws) => ( - PathList::new(&ws.read(cx).root_paths(cx)), - ws.read(cx).project_group_key(cx), - ), - ThreadEntryWorkspace::Closed { - folder_paths, - project_group_key, - } => (folder_paths.clone(), project_group_key.clone()), - }; - Some((t.metadata.clone(), workspace_paths, project_group_key)) - } - _ => None, - }) - }); + let neighbor = + current_pos.and_then(|position| self.neighboring_activatable_entry(position)); // Check if archiving this thread would leave its worktree workspace // with no threads, requiring workspace removal. @@ -3188,6 +3484,10 @@ impl Sidebar { .read(cx) .workspace_for_paths(folder_paths, None, cx)?; + if workspace_has_agent_panel_terminals(&workspace, cx) { + return None; + } + let group_key = workspace.read(cx).project_group_key(cx); let is_linked_worktree = group_key.path_list() != folder_paths; @@ -3278,12 +3578,12 @@ impl Sidebar { let (fallback_paths, project_group_key) = neighbor .as_ref() - .map(|(_, paths, project_group_key)| (paths.clone(), project_group_key.clone())) + .map(|neighbor| neighbor.project_location(cx)) .unwrap_or_else(|| { workspaces_to_remove .first() - .map(|ws| { - let key = ws.read(cx).project_group_key(cx); + .map(|workspace| { + let key = workspace.read(cx).project_group_key(cx); (key.path_list().clone(), key) }) .unwrap_or_default() @@ -3314,7 +3614,6 @@ impl Sidebar { ) }); - let neighbor_metadata = neighbor.map(|(metadata, _, _)| metadata); let thread_folder_paths = thread_folder_paths.clone(); cx.spawn_in(window, async move |this, cx| { if !remove_task.await? { @@ -3333,7 +3632,7 @@ impl Sidebar { this.archive_and_activate( &session_id, thread_id, - neighbor_metadata.as_ref(), + neighbor.as_ref(), thread_folder_paths.as_ref(), in_flight, window, @@ -3345,7 +3644,6 @@ impl Sidebar { .detach_and_log_err(cx); } else if !close_item_tasks.is_empty() { let session_id = session_id.clone(); - let neighbor_metadata = neighbor.map(|(metadata, _, _)| metadata); let thread_folder_paths = thread_folder_paths.clone(); cx.spawn_in(window, async move |this, cx| { for task in close_item_tasks { @@ -3360,7 +3658,7 @@ impl Sidebar { this.archive_and_activate( &session_id, thread_id, - neighbor_metadata.as_ref(), + neighbor.as_ref(), thread_folder_paths.as_ref(), in_flight, window, @@ -3371,13 +3669,12 @@ impl Sidebar { }) .detach_and_log_err(cx); } else { - let neighbor_metadata = neighbor.map(|(metadata, _, _)| metadata); let in_flight = thread_id .and_then(|tid| self.start_archive_worktree_task(tid, roots_to_archive, cx)); self.archive_and_activate( session_id, thread_id, - neighbor_metadata.as_ref(), + neighbor.as_ref(), thread_folder_paths.as_ref(), in_flight, window, @@ -3406,7 +3703,7 @@ impl Sidebar { &mut self, _session_id: &acp::SessionId, thread_id: Option, - neighbor: Option<&ThreadMetadata>, + neighbor: Option<&ActivatableEntry>, thread_folder_paths: Option<&PathList>, in_flight_archive: Option<(Task<()>, async_channel::Sender<()>)>, window: &mut Window, @@ -3456,25 +3753,8 @@ impl Sidebar { return; } - // Try to activate the neighbor thread. If its workspace is open, - // tell the panel to load it and activate that workspace. - // `rebuild_contents` will reconcile `active_entry` once the thread - // finishes loading. - - if let Some(metadata) = neighbor { - if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| { - mw.read(cx) - .workspace_for_paths(metadata.folder_paths(), None, cx) - }) { - self.active_entry = Some(ActiveEntry { - thread_id: metadata.thread_id, - session_id: metadata.session_id.clone(), - workspace: workspace.clone(), - }); - self.activate_workspace(&workspace, window, cx); - Self::load_agent_thread_in_workspace(&workspace, metadata, true, window, cx); - return; - } + if neighbor.is_some_and(|neighbor| self.activate_entry(neighbor, window, cx)) { + return; } // No neighbor or its workspace isn't open — just clear the @@ -3633,6 +3913,38 @@ impl Sidebar { metadata.interacted_at.unwrap_or(metadata.updated_at) } + fn push_entries_by_display_time( + entries: &mut Vec, + terminals: Vec, + threads: Vec, + current_session_ids: &mut HashSet, + current_thread_ids: &mut HashSet, + ) { + fn display_time(entry: &ListEntry) -> DateTime { + match entry { + ListEntry::Thread(thread) => Sidebar::thread_display_time(&thread.metadata), + ListEntry::Terminal(terminal) => terminal.created_at, + ListEntry::ProjectHeader { .. } => unreachable!(), + } + } + + let row_entries = terminals + .into_iter() + .map(ListEntry::Terminal) + .chain(threads.into_iter().map(ListEntry::Thread)) + .sorted_by_key(|right| std::cmp::Reverse(display_time(right))); + + for entry in row_entries { + if let ListEntry::Thread(thread) = &entry { + if let Some(session_id) = &thread.metadata.session_id { + current_session_ids.insert(session_id.clone()); + } + current_thread_ids.insert(thread.metadata.thread_id); + } + entries.push(entry); + } + } + /// The sort order used by the ctrl-tab switcher fn thread_cmp_for_switcher(&self, left: &ThreadMetadata, right: &ThreadMetadata) -> Ordering { let sort_time = |x: &ThreadMetadata| { @@ -3704,6 +4016,7 @@ impl Sidebar { timestamp, }) } + ListEntry::Terminal(_) => None, }) .collect(); @@ -3755,8 +4068,11 @@ impl Sidebar { let weak_multi_workspace = self.multi_workspace.clone(); - let original_metadata = match &self.active_entry { - Some(ActiveEntry { thread_id, .. }) => entries + // Capture the full active entry so dismissal can restore terminal + // entries too, not just threads. + let original_active_entry = self.active_entry.clone(); + let original_metadata = match &original_active_entry { + Some(ActiveEntry::Thread { thread_id, .. }) => entries .iter() .find(|e| *thread_id == e.metadata.thread_id) .map(|e| e.metadata.clone()), @@ -3783,7 +4099,7 @@ impl Sidebar { mw.activate(workspace.clone(), None, window, cx); }); } - this.active_entry = Some(ActiveEntry { + this.active_entry = Some(ActiveEntry::Thread { thread_id: metadata.thread_id, session_id: metadata.session_id.clone(), workspace: workspace.clone(), @@ -3804,7 +4120,7 @@ impl Sidebar { }); } this.record_thread_access(&metadata.thread_id); - this.active_entry = Some(ActiveEntry { + this.active_entry = Some(ActiveEntry::Thread { thread_id: metadata.thread_id, session_id: metadata.session_id.clone(), workspace: workspace.clone(), @@ -3821,24 +4137,46 @@ impl Sidebar { }); } } - if let Some(metadata) = &original_metadata { - if let Some(original_ws) = &original_workspace { - this.active_entry = Some(ActiveEntry { - thread_id: metadata.thread_id, - session_id: metadata.session_id.clone(), - workspace: original_ws.clone(), - }); + match &original_active_entry { + Some(ActiveEntry::Thread { .. }) => { + if let (Some(metadata), Some(original_ws)) = + (&original_metadata, &original_workspace) + { + this.active_entry = Some(ActiveEntry::Thread { + thread_id: metadata.thread_id, + session_id: metadata.session_id.clone(), + workspace: original_ws.clone(), + }); + this.update_entries(cx); + Self::load_agent_thread_in_workspace( + original_ws, + metadata, + false, + window, + cx, + ); + } } - this.update_entries(cx); - if let Some(original_ws) = &original_workspace { - Self::load_agent_thread_in_workspace( - original_ws, - metadata, - false, - window, - cx, - ); + Some(ActiveEntry::Terminal { + terminal_id, + workspace, + }) => { + let terminal_id = *terminal_id; + let workspace = workspace.clone(); + this.active_entry = Some(ActiveEntry::Terminal { + terminal_id, + workspace: workspace.clone(), + }); + this.update_entries(cx); + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.activate_terminal(terminal_id, false, window, cx); + }); + } + }); } + None => {} } this.dismiss_thread_switcher(cx); } @@ -3877,7 +4215,7 @@ impl Sidebar { mw.activate(workspace.clone(), None, window, cx); }); } - self.active_entry = Some(ActiveEntry { + self.active_entry = Some(ActiveEntry::Thread { thread_id: metadata.thread_id, session_id: metadata.session_id.clone(), workspace: workspace.clone(), @@ -4025,6 +4363,61 @@ impl Sidebar { .into_any_element() } + fn render_terminal( + &self, + ix: usize, + terminal: &TerminalEntry, + is_active: bool, + is_focused: bool, + cx: &mut Context, + ) -> AnyElement { + let id = ElementId::from(format!("terminal-{}", terminal.id)); + let timestamp = format_history_entry_timestamp(terminal.created_at); + let is_hovered = self.hovered_thread_index == Some(ix); + let color = cx.theme().colors(); + let sidebar_bg = color + .title_bar_background + .blend(color.panel_background.opacity(0.25)); + let terminal_id = terminal.id; + let workspace = terminal.workspace.clone(); + + ThreadItem::new(id, terminal.title.clone()) + .base_bg(sidebar_bg) + .icon(IconName::Terminal) + .timestamp(timestamp) + .notified(terminal.has_notification) + .highlight_positions(terminal.highlight_positions.clone()) + .selected(is_active) + .focused(is_focused) + .hovered(is_hovered) + .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| { + if *is_hovered { + this.hovered_thread_index = Some(ix); + } else if this.hovered_thread_index == Some(ix) { + this.hovered_thread_index = None; + } + cx.notify(); + })) + .when(is_hovered, |this| { + this.action_slot( + IconButton::new("close-terminal", IconName::Close) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Close Terminal")) + .on_click(cx.listener(move |this, _, window, cx| { + this.close_terminal(&workspace, terminal_id, window, cx); + })), + ) + }) + .on_click(cx.listener({ + let workspace = terminal.workspace.clone(); + move |this, _, window, cx| { + this.activate_terminal(&workspace, terminal_id, false, window, cx); + } + })) + .into_any_element() + } + fn render_filter_input(&self, cx: &mut Context) -> impl IntoElement { div() .min_w_0() @@ -4101,15 +4494,39 @@ impl Sidebar { self.set_group_expanded(&key, true, cx); self.selection = None; if let Some(workspace) = self.workspace_for_group(&key, cx) { - self.create_new_thread(&workspace, window, cx); + self.create_new_entry(&workspace, window, cx); } else { - self.open_workspace_and_create_draft(&key, window, cx); + self.open_workspace_and_create_entry(&key, window, cx); } } else if let Some(workspace) = self.active_workspace(cx) { - self.create_new_thread(&workspace, window, cx); + self.create_new_entry(&workspace, window, cx); + } + } + + fn create_new_entry( + &mut self, + workspace: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + if self.should_create_terminal_for_workspace(workspace, cx) { + self.create_new_terminal(workspace, window, cx); + } else { + self.create_new_thread(workspace, window, cx); } } + fn should_create_terminal_for_workspace( + &self, + workspace: &Entity, + cx: &App, + ) -> bool { + workspace + .read(cx) + .panel::(cx) + .is_some_and(|panel| panel.read(cx).should_create_terminal_for_new_entry(cx)) + } + fn create_new_thread( &mut self, workspace: &Entity, @@ -4127,7 +4544,7 @@ impl Sidebar { let draft_id = workspace.update(cx, |workspace, cx| { let panel = workspace.panel::(cx)?; let draft_id = panel.update(cx, |panel, cx| { - panel.activate_draft(true, "sidebar", window, cx); + panel.activate_new_thread(true, "sidebar", window, cx); panel.active_thread_id(cx) }); workspace.focus_panel::(window, cx); @@ -4135,7 +4552,7 @@ impl Sidebar { }); if let Some(draft_id) = draft_id { - self.active_entry = Some(ActiveEntry { + self.active_entry = Some(ActiveEntry::Thread { thread_id: draft_id, session_id: None, workspace: workspace.clone(), @@ -4143,11 +4560,35 @@ impl Sidebar { } } + fn create_new_terminal( + &mut self, + workspace: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.activate(workspace.clone(), None, window, cx); + }); + + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.new_terminal(Some(workspace), window, cx); + }); + } + workspace.focus_panel::(window, cx); + }); + } + fn selected_group_key(&self) -> Option { let ix = self.selection?; match self.contents.entries.get(ix) { Some(ListEntry::ProjectHeader { key, .. }) => Some(key.clone()), - Some(ListEntry::Thread(_)) => { + Some(ListEntry::Thread(_) | ListEntry::Terminal(_)) => { (0..ix) .rev() .find_map(|i| match self.contents.entries.get(i) { @@ -4279,7 +4720,7 @@ impl Sidebar { .iter() .enumerate() .filter_map(|(ix, entry)| match entry { - ListEntry::Thread(_) => Some(ix), + ListEntry::Thread(_) | ListEntry::Terminal(_) => Some(ix), _ => None, }) .collect(); @@ -4307,30 +4748,35 @@ impl Sidebar { }; let entry_ix = thread_indices[next_pos]; - let ListEntry::Thread(thread) = &self.contents.entries[entry_ix] else { - return; - }; - - let metadata = thread.metadata.clone(); - match &thread.workspace { - ThreadEntryWorkspace::Open(workspace) => { - let workspace = workspace.clone(); - self.activate_thread(metadata, &workspace, true, window, cx); + match &self.contents.entries[entry_ix] { + ListEntry::Thread(thread) => { + let metadata = thread.metadata.clone(); + match &thread.workspace { + ThreadEntryWorkspace::Open(workspace) => { + let workspace = workspace.clone(); + self.activate_thread(metadata, &workspace, true, window, cx); + } + ThreadEntryWorkspace::Closed { + folder_paths, + project_group_key, + } => { + let folder_paths = folder_paths.clone(); + let project_group_key = project_group_key.clone(); + self.open_workspace_and_activate_thread( + metadata, + folder_paths, + &project_group_key, + window, + cx, + ); + } + } } - ThreadEntryWorkspace::Closed { - folder_paths, - project_group_key, - } => { - let folder_paths = folder_paths.clone(); - let project_group_key = project_group_key.clone(); - self.open_workspace_and_activate_thread( - metadata, - folder_paths, - &project_group_key, - window, - cx, - ); + ListEntry::Terminal(terminal) => { + let workspace = terminal.workspace.clone(); + self.activate_terminal(&workspace, terminal.id, true, window, cx); } + ListEntry::ProjectHeader { .. } => {} } } @@ -4868,7 +5314,7 @@ impl WorkspaceSidebar for Sidebar { } fn has_notifications(&self, _cx: &App) -> bool { - !self.contents.notified_threads.is_empty() + !self.contents.notified_threads.is_empty() || !self.contents.notified_terminals.is_empty() } fn is_threads_list_view_active(&self) -> bool { @@ -5044,6 +5490,33 @@ impl Render for Sidebar { } } +fn terminal_entries_for_workspace( + workspace: &Entity, + cx: &App, +) -> impl Iterator { + if !cx.has_flag::() { + return None.into_iter().flatten(); + } + let Some(agent_panel) = workspace.read(cx).panel::(cx) else { + return None.into_iter().flatten(); + }; + let terminals = + agent_panel + .read(cx) + .terminals(cx) + .into_iter() + .map(|terminal: AgentPanelTerminalInfo| TerminalEntry { + id: terminal.id, + title: terminal.title, + workspace: workspace.clone(), + created_at: terminal.created_at, + has_notification: terminal.has_notification, + highlight_positions: Vec::new(), + }); + + Some(terminals).into_iter().flatten() +} + fn all_thread_infos_for_workspace( workspace: &Entity, cx: &App, diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 3747a7a4d3940d..dc806009a9fc05 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -33,13 +33,17 @@ fn init_test(cx: &mut TestAppContext) { }); } +fn enable_agent_panel_terminal(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.update_flags(true, vec!["agent-panel-terminal".to_string()]); + }); +} + #[track_caller] fn assert_active_thread(sidebar: &Sidebar, session_id: &acp::SessionId, msg: &str) { let active = sidebar.active_entry.as_ref(); let matches = active.is_some_and(|entry| { - // Match by session_id directly on active_entry. - entry.session_id.as_ref() == Some(session_id) - // Or match by finding the thread in sidebar entries. + matches!(entry, ActiveEntry::Thread { session_id: Some(active_session_id), .. } if active_session_id == session_id) || sidebar.contents.entries.iter().any(|list_entry| { matches!(list_entry, ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id) @@ -67,7 +71,7 @@ fn is_active_session(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool { }); match thread_id { Some(tid) => { - matches!(&sidebar.active_entry, Some(ActiveEntry { thread_id, .. }) if *thread_id == tid) + matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { thread_id, .. }) if *thread_id == tid) } // Thread not in sidebar entries — can't confirm it's active. None => false, @@ -77,7 +81,7 @@ fn is_active_session(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool { #[track_caller] fn assert_active_draft(sidebar: &Sidebar, workspace: &Entity, msg: &str) { assert!( - matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == workspace), + matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { workspace: ws, .. }) if ws == workspace), "{msg}: expected active_entry to be Draft for workspace {:?}, got {:?}", workspace.entity_id(), sidebar.active_entry, @@ -147,6 +151,12 @@ fn assert_remote_project_integration_sidebar_state( title ); } + ListEntry::Terminal(terminal) => { + panic!( + "unexpected sidebar terminal while simulating remote project integration flicker: title=`{}`", + terminal.title + ); + } } } @@ -517,6 +527,10 @@ fn visible_entries_as_strings( format!(" {title}{worktree}{live}{status_str}{notified}{selected}") } } + ListEntry::Terminal(terminal) => { + let title = &terminal.title; + format!(" {title}{selected}") + } } }) .collect() @@ -1408,6 +1422,150 @@ fn setup_sidebar_with_agent_panel( (sidebar, panel) } +#[gpui::test] +async fn test_agent_panel_terminals_appear_in_sidebar_and_search(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + enable_agent_panel_terminal(cx); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + let terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Dev Server"] + ); + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + matches!(&sidebar.active_entry, Some(ActiveEntry::Terminal { terminal_id: active_terminal_id, .. }) if *active_terminal_id == terminal_id), + "expected active terminal entry, got {:?}", + sidebar.active_entry, + ); + assert!( + sidebar.contents.entries.iter().any(|entry| { + matches!(entry, ListEntry::Terminal(terminal) if terminal.id == terminal_id && terminal.title.as_ref() == "Dev Server") + }), + "expected the inserted terminal to appear in sidebar contents", + ); + }); + + type_in_search(&sidebar, "server", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Dev Server <== selected"] + ); + + type_in_search(&sidebar, "missing", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + Vec::::new() + ); +} + +#[gpui::test] +async fn test_agent_panel_terminal_notifications_update_sidebar(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + enable_agent_panel_terminal(cx); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + let build_terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Build", true, window, cx) + }) + .expect("build test terminal should be inserted"); + let server_terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Server", true, window, cx) + }) + .expect("server test terminal should be inserted"); + cx.run_until_parked(); + + panel.read_with(cx, |panel, _cx| { + assert_eq!(panel.active_terminal_id(), Some(server_terminal_id)); + }); + + panel.update(cx, |panel, cx| { + panel.emit_test_terminal_bell(build_terminal_id, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, cx| { + assert!(sidebar.has_notifications(cx)); + assert!(sidebar.contents.notified_terminals.contains(&build_terminal_id)); + assert!(sidebar.contents.entries.iter().any(|entry| { + matches!(entry, ListEntry::Terminal(terminal) if terminal.id == build_terminal_id && terminal.has_notification) + })); + }); + + panel.update_in(cx, |panel, window, cx| { + panel.activate_terminal(build_terminal_id, true, window, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, cx| { + assert!(!sidebar.has_notifications(cx)); + assert!( + !sidebar + .contents + .notified_terminals + .contains(&build_terminal_id) + ); + }); +} + +#[gpui::test] +async fn test_closing_active_agent_panel_terminal_activates_neighbor(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + enable_agent_panel_terminal(cx); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + let workspace = multi_workspace.read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }); + + let build_terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Build", true, window, cx) + }) + .expect("build test terminal should be inserted"); + let server_terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Server", true, window, cx) + }) + .expect("server test terminal should be inserted"); + cx.run_until_parked(); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.close_terminal(&workspace, server_terminal_id, window, cx); + }); + cx.run_until_parked(); + + panel.read_with(cx, |panel, _cx| { + assert!(!panel.has_terminal(server_terminal_id)); + assert_eq!(panel.active_terminal_id(), Some(build_terminal_id)); + }); + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + matches!(&sidebar.active_entry, Some(ActiveEntry::Terminal { terminal_id, .. }) if *terminal_id == build_terminal_id), + "expected remaining terminal to become active, got {:?}", + sidebar.active_entry, + ); + }); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Build"] + ); +} + #[gpui::test] async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) { let project = init_test_project_with_agent_panel("/my-project", cx).await; @@ -2740,7 +2898,7 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex // because the panel has a thread with messages. sidebar.read_with(cx, |sidebar, _cx| { assert!( - matches!(&sidebar.active_entry, Some(ActiveEntry { .. })), + matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })), "Panel has a thread with messages, so active_entry should be Thread, got {:?}", sidebar.active_entry, ); @@ -2776,7 +2934,7 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex // false — the panel still has the old thread with messages. sidebar.read_with(cx, |sidebar, _cx| { assert!( - matches!(&sidebar.active_entry, Some(ActiveEntry { .. })), + matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })), "After adding a folder the panel still has a thread with messages, \ so active_entry should be Thread, got {:?}", sidebar.active_entry, @@ -3873,6 +4031,12 @@ async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_proje title, worktree_name ); } + ListEntry::Terminal(terminal) => { + panic!( + "unexpected sidebar terminal while opening linked worktree thread: title=`{}`", + terminal.title + ); + } } } @@ -6241,7 +6405,7 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) { // active_entry should still be a draft on workspace_b (the active one). sidebar.read_with(cx, |sidebar, _| { assert!( - matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b), + matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { workspace: ws, .. }) if ws == &workspace_b), "expected Draft(workspace_b) after archiving non-active thread, got: {:?}", sidebar.active_entry, ); @@ -6278,7 +6442,7 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) { // sidebar row but active_entry tracks it. sidebar.read_with(cx, |sidebar, _| { assert!( - matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b), + matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { workspace: ws, .. }) if ws == &workspace_b), "expected draft on workspace_b after archiving active thread, got: {:?}", sidebar.active_entry, ); @@ -9773,7 +9937,7 @@ mod property_test { // 3. The entry must match the agent panel's current state. if panel.read(cx).active_thread_id(cx).is_some() { anyhow::ensure!( - matches!(entry, ActiveEntry { .. }), + matches!(entry, ActiveEntry::Thread { .. }), "panel shows a tracked draft but active_entry is {:?}", entry, ); @@ -9783,7 +9947,7 @@ mod property_test { .map(|cv| cv.read(cx).parent_id()) { anyhow::ensure!( - matches!(entry, ActiveEntry { thread_id: tid, .. } if *tid == thread_id), + matches!(entry, ActiveEntry::Thread { thread_id: tid, .. } if *tid == thread_id), "panel has thread {:?} but active_entry is {:?}", thread_id, entry, @@ -9795,8 +9959,11 @@ mod property_test { // a draft, which is represented by the + button's active state // rather than a sidebar row. // TODO: Make this check more complete - let is_draft = panel.read(cx).active_thread_is_draft(cx) - || panel.read(cx).active_conversation_view().is_none(); + // Active terminals must still match a row, so don't treat the absence + // of a conversation view as "draft" when a terminal is active. + let is_draft = panel.read(cx).active_terminal_id().is_none() + && (panel.read(cx).active_thread_is_draft(cx) + || panel.read(cx).active_conversation_view().is_none()); if is_draft { return Ok(()); } diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 07c638c16048c3..37c4c165836fa9 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -2005,7 +2005,7 @@ impl SearchableItem for TerminalView { /// For remote projects, local-only resolution (home dir fallback, shell expansion, /// local `is_dir` checks) is skipped -- returning `None` lets the remote shell /// open in the remote user's home directory by default. -pub(crate) fn default_working_directory(workspace: &Workspace, cx: &App) -> Option { +pub fn default_working_directory(workspace: &Workspace, cx: &App) -> Option { let is_remote = workspace.project().read(cx).is_remote(); let directory = match &TerminalSettings::get_global(cx).working_directory { WorkingDirectory::CurrentFileDirectory => workspace From a6f41d1b8344baa42ddacbb081c0ac1f8bd71029 Mon Sep 17 00:00:00 2001 From: Katie Geer Date: Thu, 7 May 2026 08:43:43 -0700 Subject: [PATCH 21/33] Fix sign in disclaimer to accurately show trial benefits (#55964) Self-Review Checklist: - [ x] I've reviewed my own diff for quality, security, and reliability - [x ] Unsafe blocks (if any) have justifying comments - [x ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x ] Tests cover the new/changed behavior - [x ] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/ai_onboarding/src/ai_onboarding.rs | 4 ++-- crates/ai_onboarding/src/plan_definitions.rs | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index bc1dabefd28cc5..30aaa4206fe18d 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -156,11 +156,11 @@ impl ZedAiOnboarding { .gap_1() .child(Headline::new("Welcome to Zed AI")) .child( - Label::new("Sign in to try Zed Pro for 14 days, no credit card required.") + Label::new("Sign in to try Zed Pro free for 14 days.") .color(Color::Muted) .mb_2(), ) - .child(PlanDefinitions.pro_plan()) + .child(PlanDefinitions.sign_in_upsell()) .child( Button::new("sign_in", "Try Zed Pro for Free") .disabled(signing_in) diff --git a/crates/ai_onboarding/src/plan_definitions.rs b/crates/ai_onboarding/src/plan_definitions.rs index cc80b5ccf6d3d6..2ac7aeab56678c 100644 --- a/crates/ai_onboarding/src/plan_definitions.rs +++ b/crates/ai_onboarding/src/plan_definitions.rs @@ -14,6 +14,13 @@ impl PlanDefinitions { .child(ListBulletItem::new("Unlimited use of external agents")) } + pub fn sign_in_upsell(&self) -> impl IntoElement { + List::new() + .child(ListBulletItem::new("Unlimited edit predictions")) + .child(ListBulletItem::new("$20 of tokens in Zed agent")) + .child(ListBulletItem::new("No credit card required")) + } + pub fn pro_trial(&self, period: bool) -> impl IntoElement { List::new() .child(ListBulletItem::new("$20 of tokens in Zed agent")) From 68256f2e1da53fbe9b7966a8b9bc69da3cf6f652 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Thu, 7 May 2026 16:56:32 +0100 Subject: [PATCH 22/33] git: Add `dev: show git job queue` (#55904) Adds a command to help debugging stuck git job queues Release Notes: - N/A or Added/Fixed/Improved ... --------- Co-authored-by: Anthony Eid --- crates/git_ui/src/git_panel.rs | 83 ++ crates/project/src/git_store.rs | 754 ++++++++++-------- .../project/src/git_store/job_debug_queue.rs | 222 ++++++ crates/project/src/telemetry_snapshot.rs | 2 +- 4 files changed, 743 insertions(+), 318 deletions(-) create mode 100644 crates/project/src/git_store/job_debug_queue.rs diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 0b6316c4adca7e..61423e39b78b0b 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -120,6 +120,14 @@ actions!( ] ); +actions!( + dev, + [ + /// Shows the current git job queue debug state for the active repository. + ShowGitJobQueue, + ] +); + actions!( git_graph, [ @@ -259,6 +267,13 @@ pub fn register(workspace: &mut Workspace) { panel.update(cx, |panel, cx| panel.git_init(window, cx)); } }); + workspace.register_action(|workspace, _: &ShowGitJobQueue, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.show_git_job_queue(window, cx); + }); + } + }); } #[derive(Debug, Clone)] @@ -3880,6 +3895,74 @@ impl GitPanel { show_error_toast(workspace, action, e, cx) } + fn show_git_job_queue(&mut self, window: &mut Window, cx: &mut Context) { + let Some(repo) = self.active_repository.as_ref() else { + let workspace = self.workspace.clone(); + cx.defer(move |cx| { + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + struct GitJobQueueToast; + workspace.show_toast( + workspace::Toast::new( + NotificationId::unique::(), + "No active repository", + ) + .autohide(), + cx, + ); + }); + } + }); + return; + }; + + let repo_path = repo.read(cx).work_directory_abs_path.display().to_string(); + let text = repo.read(cx).job_debug_queue().to_debug_string(); + let title = format!("Git Job Queue: {repo_path}"); + + let json_language = self.project.read(cx).languages().language_for_name("JSON"); + let project = self.project.clone(); + let workspace = self.workspace.clone(); + + window + .spawn(cx, async move |cx| { + let json_language = json_language.await.ok(); + + let buffer = project + .update(cx, |project, cx| { + project.create_buffer(json_language, false, cx) + }) + .await?; + + buffer.update(cx, |buffer, cx| { + buffer.set_text(text, cx); + buffer.set_capability(language::Capability::ReadWrite, cx); + }); + + workspace.update_in(cx, |workspace, window, cx| { + let buffer = + cx.new(|cx| MultiBuffer::singleton(buffer, cx).with_title(title.clone())); + + workspace.add_item_to_active_pane( + Box::new(cx.new(|cx| { + let mut editor = + Editor::for_multibuffer(buffer, Some(project.clone()), window, cx); + editor.set_breadcrumb_header(title); + editor.disable_mouse_wheel_zoom(); + editor + })), + None, + true, + window, + cx, + ); + })?; + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + fn show_commit_message_error(weak_this: &WeakEntity, err: &E, cx: &mut AsyncApp) where E: std::fmt::Debug + std::fmt::Display, diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 61cca22ff77e87..52c16e8a2ba710 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -1,6 +1,7 @@ pub mod branch_diff; mod conflict_set; pub mod git_traversal; +pub mod job_debug_queue; pub mod pending_op; use crate::{ @@ -380,6 +381,7 @@ pub struct Repository { paths_needing_status_update: Vec>, job_sender: mpsc::UnboundedSender, active_jobs: HashMap, + job_debug_queue: job_debug_queue::GitJobDebugQueue, pending_ops: SumTree, job_id: JobId, askpass_delegates: Arc>>, @@ -507,6 +509,7 @@ impl EventEmitter for Repository {} impl EventEmitter for GitStore {} pub struct GitJob { + id: JobId, job: Box Task<()>>, key: Option, } @@ -1384,7 +1387,7 @@ impl GitStore { .to_string(); let rx = repo.update(cx, |repo, _| { - repo.send_job(None, move |state, cx| async move { + repo.send_job("get_permalink_to_line", None, move |state, cx| async move { match state { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { let origin_url = backend @@ -4523,6 +4526,7 @@ impl Repository { job_sender, job_id: 0, active_jobs: Default::default(), + job_debug_queue: job_debug_queue::GitJobDebugQueue::new(), initial_graph_data: Default::default(), commit_data: Default::default(), commit_data_handler: CommitDataHandlerState::Closed, @@ -4574,6 +4578,7 @@ impl Repository { askpass_delegates: Default::default(), latest_askpass_id: 0, active_jobs: Default::default(), + job_debug_queue: job_debug_queue::GitJobDebugQueue::new(), job_id: 0, initial_graph_data: Default::default(), commit_data: Default::default(), @@ -4609,6 +4614,7 @@ impl Repository { let this = cx.weak_entity(); let git_store = self.git_store.clone(); let _ = self.send_keyed_job( + "reload_buffer_diff_bases", Some(GitJobKey::ReloadBufferDiffBases), None, |state, mut cx| async move { @@ -4768,6 +4774,7 @@ impl Repository { pub fn send_job( &mut self, + description: &'static str, status: Option, job: F, ) -> oneshot::Receiver @@ -4776,11 +4783,12 @@ impl Repository { Fut: Future + 'static, R: Send + 'static, { - self.send_keyed_job(None, status, job) + self.send_keyed_job(description, None, status, job) } fn send_keyed_job( &mut self, + description: &'static str, key: Option, status: Option, job: F, @@ -4793,29 +4801,39 @@ impl Repository { let (result_tx, result_rx) = futures::channel::oneshot::channel(); let job_id = post_inc(&mut self.job_id); let this = self.this.clone(); + + let key_label = key.as_ref().map(format_job_key); + self.job_debug_queue.add(job_id, description, key_label); + self.job_sender .unbounded_send(GitJob { + id: job_id, key, job: Box::new(move |state, cx: &mut AsyncApp| { let job = job(state, cx.clone()); cx.spawn(async move |cx| { - if let Some(s) = status.clone() { - this.update(cx, |this, cx| { + this.update(cx, |this, cx| { + this.job_debug_queue.mark_running(job_id); + if let Some(s) = status { this.active_jobs.insert( job_id, JobInfo { start: Instant::now(), - message: s.clone(), + message: s, }, ); + } + cx.notify(); + }) + .ok(); - cx.notify(); - }) - .ok(); - } let result = job.await; this.update(cx, |this, cx| { + this.job_debug_queue.mark_complete( + job_id, + job_debug_queue::CompletedJobStatus::Finished, + ); this.active_jobs.remove(&job_id); cx.notify(); }) @@ -4898,43 +4916,47 @@ impl Repository { } let this = cx.weak_entity(); - let rx = self.send_job(None, move |state, mut cx| async move { - let Some(this) = this.upgrade() else { - bail!("git store was dropped"); - }; - match state { - RepositoryState::Local(..) => { - this.update(&mut cx, |_, cx| { - Self::open_local_commit_buffer(languages, buffer_store, cx) - }) - .await - } - RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { - let request = client.request(proto::OpenCommitMessageBuffer { - project_id: project_id.0, - repository_id: id.to_proto(), - }); - let response = request.await.context("requesting to open commit buffer")?; - let buffer_id = BufferId::new(response.buffer_id)?; - let buffer = buffer_store - .update(&mut cx, |buffer_store, cx| { - buffer_store.wait_for_remote_buffer(buffer_id, cx) + let rx = self.send_job( + "open_commit_buffer", + None, + move |state, mut cx| async move { + let Some(this) = this.upgrade() else { + bail!("git store was dropped"); + }; + match state { + RepositoryState::Local(..) => { + this.update(&mut cx, |_, cx| { + Self::open_local_commit_buffer(languages, buffer_store, cx) }) - .await?; - if let Some(language_registry) = languages { - let git_commit_language = - language_registry.language_for_name("Git Commit").await?; - buffer.update(&mut cx, |buffer, cx| { - buffer.set_language(Some(git_commit_language), cx); + .await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + let request = client.request(proto::OpenCommitMessageBuffer { + project_id: project_id.0, + repository_id: id.to_proto(), + }); + let response = request.await.context("requesting to open commit buffer")?; + let buffer_id = BufferId::new(response.buffer_id)?; + let buffer = buffer_store + .update(&mut cx, |buffer_store, cx| { + buffer_store.wait_for_remote_buffer(buffer_id, cx) + }) + .await?; + if let Some(language_registry) = languages { + let git_commit_language = + language_registry.language_for_name("Git Commit").await?; + buffer.update(&mut cx, |buffer, cx| { + buffer.set_language(Some(git_commit_language), cx); + }); + } + this.update(&mut cx, |this, _| { + this.commit_message_buffer = Some(buffer.clone()); }); + Ok(buffer) } - this.update(&mut cx, |this, _| { - this.commit_message_buffer = Some(buffer.clone()); - }); - Ok(buffer) } - } - }); + }, + ); cx.spawn(|_, _: &mut AsyncApp| async move { rx.await? }) } @@ -4980,6 +5002,7 @@ impl Repository { async move |this, cx| { this.update(cx, |this, _cx| { this.send_job( + "checkout_files", Some(format!("git checkout {}", commit).into()), move |git_repo, _| async move { match git_repo { @@ -5027,7 +5050,7 @@ impl Repository { ) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |git_repo, _| async move { + self.send_job("reset", None, move |git_repo, _| async move { match git_repo { RepositoryState::Local(LocalRepositoryState { backend, @@ -5055,7 +5078,7 @@ impl Repository { pub fn show(&mut self, commit: String) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |git_repo, _cx| async move { + self.send_job("show", None, move |git_repo, _cx| async move { match git_repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.show(commit).await @@ -5083,7 +5106,7 @@ impl Repository { pub fn load_commit_diff(&mut self, commit: String) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |git_repo, cx| async move { + self.send_job("load_commit_diff", None, move |git_repo, cx| async move { match git_repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.load_commit(commit, cx).await @@ -5869,6 +5892,7 @@ impl Repository { this.update(cx, |this, cx| { let weak_this = cx.weak_entity(); this.send_keyed_job( + "stage_or_unstage_entries", Some(job_key), Some(status.into()), move |git_repo, mut cx| async move { @@ -6095,7 +6119,7 @@ impl Repository { cx.spawn(async move |this, cx| { this.update(cx, |this, _| { - this.send_job(None, move |git_repo, _cx| async move { + this.send_job("stash_entries", None, move |git_repo, _cx| async move { match git_repo { RepositoryState::Local(LocalRepositoryState { backend, @@ -6131,7 +6155,7 @@ impl Repository { let id = self.id; cx.spawn(async move |this, cx| { this.update(cx, |this, _| { - this.send_job(None, move |git_repo, _cx| async move { + this.send_job("stash_pop", None, move |git_repo, _cx| async move { match git_repo { RepositoryState::Local(LocalRepositoryState { backend, @@ -6165,7 +6189,7 @@ impl Repository { let id = self.id; cx.spawn(async move |this, cx| { this.update(cx, |this, _| { - this.send_job(None, move |git_repo, _cx| async move { + this.send_job("stash_apply", None, move |git_repo, _cx| async move { match git_repo { RepositoryState::Local(LocalRepositoryState { backend, @@ -6204,40 +6228,44 @@ impl Repository { path_display.to_string() }; - self.send_job(None, move |git_repo, _cx| async move { - match git_repo { - RepositoryState::Local(LocalRepositoryState { fs, .. }) => { - let gitignore_path = work_dir.join(".gitignore"); + self.send_job( + "add_path_to_gitignore", + None, + move |git_repo, _cx| async move { + match git_repo { + RepositoryState::Local(LocalRepositoryState { fs, .. }) => { + let gitignore_path = work_dir.join(".gitignore"); - let existing_content = fs.load(&gitignore_path).await.unwrap_or_default(); + let existing_content = fs.load(&gitignore_path).await.unwrap_or_default(); - if existing_content - .lines() - .any(|line| line.trim() == file_path_str) - { - return Ok(()); - } + if existing_content + .lines() + .any(|line| line.trim() == file_path_str) + { + return Ok(()); + } - let new_content = if existing_content.is_empty() { - format!("{}\n", file_path_str) - } else if existing_content.ends_with('\n') { - format!("{}{}\n", existing_content, file_path_str) - } else { - format!("{}\n{}\n", existing_content, file_path_str) - }; + let new_content = if existing_content.is_empty() { + format!("{}\n", file_path_str) + } else if existing_content.ends_with('\n') { + format!("{}{}\n", existing_content, file_path_str) + } else { + format!("{}\n{}\n", existing_content, file_path_str) + }; - fs.save( - &gitignore_path, - &text::Rope::from(new_content.as_str()), - text::LineEnding::Unix, - ) - .await + fs.save( + &gitignore_path, + &text::Rope::from(new_content.as_str()), + text::LineEnding::Unix, + ) + .await + } + RepositoryState::Remote(_) => Err(anyhow::anyhow!( + "Cannot modify .gitignore on remote repository" + )), } - RepositoryState::Remote(_) => Err(anyhow::anyhow!( - "Cannot modify .gitignore on remote repository" - )), - } - }) + }, + ) } pub fn stash_drop( @@ -6255,7 +6283,7 @@ impl Repository { _ => None, }); let this = cx.weak_entity(); - self.send_job(None, move |git_repo, mut cx| async move { + self.send_job("stash_drop", None, move |git_repo, mut cx| async move { match git_repo { RepositoryState::Local(LocalRepositoryState { backend, @@ -6299,6 +6327,7 @@ impl Repository { pub fn run_hook(&mut self, hook: RunHook, _cx: &mut App) -> oneshot::Receiver> { let id = self.id; self.send_job( + "run_hook", Some(format!("git hook {}", hook.as_str()).into()), move |git_repo, _cx| async move { match git_repo { @@ -6337,46 +6366,50 @@ impl Repository { let rx = self.run_hook(RunHook::PreCommit, cx); - self.send_job(Some("git commit".into()), move |git_repo, _cx| async move { - rx.await??; + self.send_job( + "commit", + Some("git commit".into()), + move |git_repo, _cx| async move { + rx.await??; - match git_repo { - RepositoryState::Local(LocalRepositoryState { - backend, - environment, - .. - }) => { - backend - .commit(message, name_and_email, options, askpass, environment) - .await - } - RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { - askpass_delegates.lock().insert(askpass_id, askpass); - let _defer = util::defer(|| { - let askpass_delegate = askpass_delegates.lock().remove(&askpass_id); - debug_assert!(askpass_delegate.is_some()); - }); - let (name, email) = name_and_email.unzip(); - client - .request(proto::Commit { - project_id: project_id.0, - repository_id: id.to_proto(), - message: String::from(message), - name: name.map(String::from), - email: email.map(String::from), - options: Some(proto::commit::CommitOptions { - amend: options.amend, - signoff: options.signoff, - allow_empty: options.allow_empty, - }), - askpass_id, - }) - .await?; + match git_repo { + RepositoryState::Local(LocalRepositoryState { + backend, + environment, + .. + }) => { + backend + .commit(message, name_and_email, options, askpass, environment) + .await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + askpass_delegates.lock().insert(askpass_id, askpass); + let _defer = util::defer(|| { + let askpass_delegate = askpass_delegates.lock().remove(&askpass_id); + debug_assert!(askpass_delegate.is_some()); + }); + let (name, email) = name_and_email.unzip(); + client + .request(proto::Commit { + project_id: project_id.0, + repository_id: id.to_proto(), + message: String::from(message), + name: name.map(String::from), + email: email.map(String::from), + options: Some(proto::commit::CommitOptions { + amend: options.amend, + signoff: options.signoff, + allow_empty: options.allow_empty, + }), + askpass_id, + }) + .await?; - Ok(()) + Ok(()) + } } - } - }) + }, + ) } pub fn fetch( @@ -6389,36 +6422,40 @@ impl Repository { let askpass_id = util::post_inc(&mut self.latest_askpass_id); let id = self.id; - self.send_job(Some("git fetch".into()), move |git_repo, cx| async move { - match git_repo { - RepositoryState::Local(LocalRepositoryState { - backend, - environment, - .. - }) => backend.fetch(fetch_options, askpass, environment, cx).await, - RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { - askpass_delegates.lock().insert(askpass_id, askpass); - let _defer = util::defer(|| { - let askpass_delegate = askpass_delegates.lock().remove(&askpass_id); - debug_assert!(askpass_delegate.is_some()); - }); + self.send_job( + "fetch", + Some("git fetch".into()), + move |git_repo, cx| async move { + match git_repo { + RepositoryState::Local(LocalRepositoryState { + backend, + environment, + .. + }) => backend.fetch(fetch_options, askpass, environment, cx).await, + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + askpass_delegates.lock().insert(askpass_id, askpass); + let _defer = util::defer(|| { + let askpass_delegate = askpass_delegates.lock().remove(&askpass_id); + debug_assert!(askpass_delegate.is_some()); + }); - let response = client - .request(proto::Fetch { - project_id: project_id.0, - repository_id: id.to_proto(), - askpass_id, - remote: fetch_options.to_proto(), - }) - .await?; + let response = client + .request(proto::Fetch { + project_id: project_id.0, + repository_id: id.to_proto(), + askpass_id, + remote: fetch_options.to_proto(), + }) + .await?; - Ok(RemoteCommandOutput { - stdout: response.stdout, - stderr: response.stderr, - }) + Ok(RemoteCommandOutput { + stdout: response.stdout, + stderr: response.stderr, + }) + } } - } - }) + }, + ) } pub fn push( @@ -6452,6 +6489,7 @@ impl Repository { let this = cx.weak_entity(); self.send_job( + "push", Some(format!("git push {} {} {}:{}", args, remote, branch, remote_branch).into()), move |git_repo, mut cx| async move { match git_repo { @@ -6544,48 +6582,52 @@ impl Repository { status.push_str(&format!(" {}", b)); } - self.send_job(Some(status.into()), move |git_repo, cx| async move { - match git_repo { - RepositoryState::Local(LocalRepositoryState { - backend, - environment, - .. - }) => { - backend - .pull( - branch.as_ref().map(|b| b.to_string()), - remote.to_string(), - rebase, - askpass, - environment.clone(), - cx, - ) - .await - } - RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { - askpass_delegates.lock().insert(askpass_id, askpass); - let _defer = util::defer(|| { - let askpass_delegate = askpass_delegates.lock().remove(&askpass_id); - debug_assert!(askpass_delegate.is_some()); - }); - let response = client - .request(proto::Pull { - project_id: project_id.0, - repository_id: id.to_proto(), - askpass_id, - rebase, - branch_name: branch.as_ref().map(|b| b.to_string()), - remote_name: remote.to_string(), - }) - .await?; + self.send_job( + "pull", + Some(status.into()), + move |git_repo, cx| async move { + match git_repo { + RepositoryState::Local(LocalRepositoryState { + backend, + environment, + .. + }) => { + backend + .pull( + branch.as_ref().map(|b| b.to_string()), + remote.to_string(), + rebase, + askpass, + environment.clone(), + cx, + ) + .await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + askpass_delegates.lock().insert(askpass_id, askpass); + let _defer = util::defer(|| { + let askpass_delegate = askpass_delegates.lock().remove(&askpass_id); + debug_assert!(askpass_delegate.is_some()); + }); + let response = client + .request(proto::Pull { + project_id: project_id.0, + repository_id: id.to_proto(), + askpass_id, + rebase, + branch_name: branch.as_ref().map(|b| b.to_string()), + remote_name: remote.to_string(), + }) + .await?; - Ok(RemoteCommandOutput { - stdout: response.stdout, - stderr: response.stderr, - }) + Ok(RemoteCommandOutput { + stdout: response.stdout, + stderr: response.stderr, + }) + } } - } - }) + }, + ) } fn spawn_set_index_text_job( @@ -6600,6 +6642,7 @@ impl Repository { let git_store = self.git_store.clone(); let abs_path = self.snapshot.repo_path_to_abs_path(&path); self.send_keyed_job( + "spawn_set_index_text_job", Some(GitJobKey::WriteIndex(vec![path.clone()])), None, move |git_repo, mut cx| async move { @@ -6674,6 +6717,7 @@ impl Repository { ) -> oneshot::Receiver> { let id = self.id; self.send_job( + "create_remote", Some(format!("git remote add {remote_name} {remote_url}").into()), move |repo, _cx| async move { match repo { @@ -6700,6 +6744,7 @@ impl Repository { pub fn remove_remote(&mut self, remote_name: String) -> oneshot::Receiver> { let id = self.id; self.send_job( + "remove_remote", Some(format!("git remove remote {remote_name}").into()), move |repo, _cx| async move { match repo { @@ -6728,7 +6773,7 @@ impl Repository { is_push: bool, ) -> oneshot::Receiver>> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { + self.send_job("get_remotes", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { let remote = if let Some(branch_name) = branch_name { @@ -6772,7 +6817,7 @@ impl Repository { pub fn branches(&mut self) -> oneshot::Receiver>> { let id = self.id; - self.send_job(None, move |repo, _| async move { + self.send_job("branches", None, move |repo, _| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.branches().await @@ -6831,7 +6876,7 @@ impl Repository { pub fn worktrees(&mut self) -> oneshot::Receiver>> { let id = self.id; - self.send_job(None, move |repo, _| async move { + self.send_job("worktrees", None, move |repo, _| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.worktrees().await @@ -6866,38 +6911,42 @@ impl Repository { Some(branch_name) => format!("git worktree add: {branch_name}"), None => "git worktree add (detached)".to_string(), }; - self.send_job(Some(job_description.into()), move |repo, _cx| async move { - match repo { - RepositoryState::Local(LocalRepositoryState { backend, .. }) => { - backend.create_worktree(target, path).await - } - RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { - let (name, commit, use_existing_branch) = match target { - CreateWorktreeTarget::ExistingBranch { branch_name } => { - (Some(branch_name), None, true) - } - CreateWorktreeTarget::NewBranch { - branch_name, - base_sha, - } => (Some(branch_name), base_sha, false), - CreateWorktreeTarget::Detached { base_sha } => (None, base_sha, false), - }; + self.send_job( + "create_worktree", + Some(job_description.into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.create_worktree(target, path).await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + let (name, commit, use_existing_branch) = match target { + CreateWorktreeTarget::ExistingBranch { branch_name } => { + (Some(branch_name), None, true) + } + CreateWorktreeTarget::NewBranch { + branch_name, + base_sha, + } => (Some(branch_name), base_sha, false), + CreateWorktreeTarget::Detached { base_sha } => (None, base_sha, false), + }; - client - .request(proto::GitCreateWorktree { - project_id: project_id.0, - repository_id: id.to_proto(), - name: name.unwrap_or_default(), - directory: path.to_string_lossy().to_string(), - commit, - use_existing_branch, - }) - .await?; + client + .request(proto::GitCreateWorktree { + project_id: project_id.0, + repository_id: id.to_proto(), + name: name.unwrap_or_default(), + directory: path.to_string_lossy().to_string(), + commit, + use_existing_branch, + }) + .await?; - Ok(()) + Ok(()) + } } - } - }) + }, + ) } pub fn create_worktree_detached( @@ -6924,24 +6973,30 @@ impl Repository { } else { format!("git checkout {branch_name}") }; - self.send_job(Some(description.into()), move |repo, _cx| async move { - match repo { - RepositoryState::Local(LocalRepositoryState { backend, .. }) => { - backend - .checkout_branch_in_worktree(branch_name, worktree_path, create) - .await - } - RepositoryState::Remote(_) => { - log::warn!("checkout_branch_in_worktree not supported for remote repositories"); - Ok(()) + self.send_job( + "checkout_branch_in_worktree", + Some(description.into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend + .checkout_branch_in_worktree(branch_name, worktree_path, create) + .await + } + RepositoryState::Remote(_) => { + log::warn!( + "checkout_branch_in_worktree not supported for remote repositories" + ); + Ok(()) + } } - } - }) + }, + ) } pub fn head_sha(&mut self) -> oneshot::Receiver>> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { + self.send_job("head_sha", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { Ok(backend.head_sha().await) @@ -6966,7 +7021,7 @@ impl Repository { commit: Option, ) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { + self.send_job("edit_ref", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => match commit { Some(commit) => backend.update_ref(ref_name, commit).await, @@ -7007,7 +7062,7 @@ impl Repository { pub fn repair_worktrees(&mut self) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { + self.send_job("repair_worktrees", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.repair_worktrees().await @@ -7027,22 +7082,26 @@ impl Repository { pub fn create_archive_checkpoint(&mut self) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { - match repo { - RepositoryState::Local(LocalRepositoryState { backend, .. }) => { - backend.create_archive_checkpoint().await - } - RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { - let response = client - .request(proto::GitCreateArchiveCheckpoint { - project_id: project_id.0, - repository_id: id.to_proto(), - }) - .await?; - Ok((response.staged_commit_sha, response.unstaged_commit_sha)) + self.send_job( + "create_archive_checkpoint", + None, + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.create_archive_checkpoint().await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + let response = client + .request(proto::GitCreateArchiveCheckpoint { + project_id: project_id.0, + repository_id: id.to_proto(), + }) + .await?; + Ok((response.staged_commit_sha, response.unstaged_commit_sha)) + } } - } - }) + }, + ) } pub fn restore_archive_checkpoint( @@ -7051,26 +7110,30 @@ impl Repository { unstaged_sha: String, ) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { - match repo { - RepositoryState::Local(LocalRepositoryState { backend, .. }) => { - backend - .restore_archive_checkpoint(staged_sha, unstaged_sha) - .await - } - RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { - client - .request(proto::GitRestoreArchiveCheckpoint { - project_id: project_id.0, - repository_id: id.to_proto(), - staged_commit_sha: staged_sha, - unstaged_commit_sha: unstaged_sha, - }) - .await?; - Ok(()) + self.send_job( + "restore_archive_checkpoint", + None, + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend + .restore_archive_checkpoint(staged_sha, unstaged_sha) + .await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitRestoreArchiveCheckpoint { + project_id: project_id.0, + repository_id: id.to_proto(), + staged_commit_sha: staged_sha, + unstaged_commit_sha: unstaged_sha, + }) + .await?; + Ok(()) + } } - } - }) + }, + ) } pub fn remove_worktree(&mut self, path: PathBuf, force: bool) -> oneshot::Receiver> { @@ -7081,6 +7144,7 @@ impl Repository { .unwrap_or(self.snapshot.common_dir_abs_path.as_ref()) .into(); self.send_job( + "remove_worktree", Some(format!("git worktree remove: {}", path.display()).into()), move |repo, cx| async move { match repo { @@ -7165,6 +7229,7 @@ impl Repository { ) -> oneshot::Receiver> { let id = self.id; self.send_job( + "rename_worktree", Some(format!("git worktree move: {}", old_path.display()).into()), move |repo, _cx| async move { match repo { @@ -7193,7 +7258,7 @@ impl Repository { include_remote_name: bool, ) -> oneshot::Receiver>> { let id = self.id; - self.send_job(None, move |repo, _| async move { + self.send_job("default_branch", None, move |repo, _| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.default_branch(include_remote_name).await @@ -7218,7 +7283,7 @@ impl Repository { _cx: &App, ) -> oneshot::Receiver> { let repository_id = self.snapshot.id; - self.send_job(None, move |repo, _cx| async move { + self.send_job("diff_tree", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.diff_tree(diff_type).await @@ -7274,7 +7339,7 @@ impl Repository { pub fn diff(&mut self, diff_type: DiffType, _cx: &App) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { + self.send_job("diff", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.diff(diff_type).await @@ -7318,30 +7383,35 @@ impl Repository { } else { format!("git switch -c {branch_name}").into() }; - self.send_job(Some(status_msg), move |repo, _cx| async move { - match repo { - RepositoryState::Local(LocalRepositoryState { backend, .. }) => { - backend.create_branch(branch_name, base_branch).await - } - RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { - client - .request(proto::GitCreateBranch { - project_id: project_id.0, - repository_id: id.to_proto(), - branch_name, - base_branch, - }) - .await?; + self.send_job( + "create_branch", + Some(status_msg), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.create_branch(branch_name, base_branch).await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitCreateBranch { + project_id: project_id.0, + repository_id: id.to_proto(), + branch_name, + base_branch, + }) + .await?; - Ok(()) + Ok(()) + } } - } - }) + }, + ) } pub fn change_branch(&mut self, branch_name: String) -> oneshot::Receiver> { let id = self.id; self.send_job( + "change_branch", Some(format!("git switch {branch_name}").into()), move |repo, _cx| async move { match repo { @@ -7373,6 +7443,7 @@ impl Repository { let id = self.id; let flag = delete_branch_flag(is_remote, force); self.send_job( + "delete_branch", Some(format!("git branch {flag} {branch_name}").into()), move |repo, _cx| async move { match repo { @@ -7407,6 +7478,7 @@ impl Repository { ) -> oneshot::Receiver> { let id = self.id; self.send_job( + "rename_branch", Some(format!("git branch -m {branch} {new_name}").into()), move |repo, _cx| async move { match repo { @@ -7432,30 +7504,34 @@ impl Repository { pub fn check_for_pushed_commits(&mut self) -> oneshot::Receiver>> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { - match repo { - RepositoryState::Local(LocalRepositoryState { backend, .. }) => { - backend.check_for_pushed_commit().await - } - RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { - let response = client - .request(proto::CheckForPushedCommits { - project_id: project_id.0, - repository_id: id.to_proto(), - }) - .await?; + self.send_job( + "check_for_pushed_commits", + None, + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.check_for_pushed_commit().await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + let response = client + .request(proto::CheckForPushedCommits { + project_id: project_id.0, + repository_id: id.to_proto(), + }) + .await?; - let branches = response.pushed_to.into_iter().map(Into::into).collect(); + let branches = response.pushed_to.into_iter().map(Into::into).collect(); - Ok(branches) + Ok(branches) + } } - } - }) + }, + ) } pub fn checkpoint(&mut self) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { + self.send_job("checkpoint", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.checkpoint().await @@ -7481,7 +7557,7 @@ impl Repository { checkpoint: GitRepositoryCheckpoint, ) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { + self.send_job("restore_checkpoint", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.restore_checkpoint(checkpoint).await @@ -7603,7 +7679,7 @@ impl Repository { right: GitRepositoryCheckpoint, ) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { + self.send_job("compare_checkpoints", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.compare_checkpoints(left, right).await @@ -7629,7 +7705,7 @@ impl Repository { target_checkpoint: GitRepositoryCheckpoint, ) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { + self.send_job("diff_checkpoints", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend @@ -7684,6 +7760,7 @@ impl Repository { ) { let this = cx.weak_entity(); let _ = self.send_keyed_job( + "schedule_scan", Some(GitJobKey::ReloadGitState), None, |state, mut cx| async move { @@ -7715,7 +7792,7 @@ impl Repository { ) -> mpsc::UnboundedSender { let (job_tx, mut job_rx) = mpsc::unbounded::(); - cx.spawn(async move |_, cx| { + cx.spawn(async move |this, cx| { let state = state.await.map_err(|err| anyhow::anyhow!(err))?; if let Some(git_hosting_provider_registry) = cx.update(|cx| GitHostingProviderRegistry::try_global(cx)) @@ -7739,6 +7816,14 @@ impl Repository { .iter() .any(|other_job| other_job.key.as_ref() == Some(current_key)) { + let skipped_job_id = job.id; + this.update(cx, |repo, _| { + repo.job_debug_queue.mark_complete( + skipped_job_id, + job_debug_queue::CompletedJobStatus::Skipped, + ); + }) + .ok(); continue; } (job.job)(state.clone(), cx).await; @@ -7761,7 +7846,7 @@ impl Repository { ) -> mpsc::UnboundedSender { let (job_tx, mut job_rx) = mpsc::unbounded::(); - cx.spawn(async move |_, cx| { + cx.spawn(async move |this, cx| { let state = RepositoryState::Remote(state); let mut jobs = VecDeque::new(); loop { @@ -7775,6 +7860,14 @@ impl Repository { .iter() .any(|other_job| other_job.key.as_ref() == Some(current_key)) { + let skipped_job_id = job.id; + this.update(cx, |repo, _| { + repo.job_debug_queue.mark_complete( + skipped_job_id, + job_debug_queue::CompletedJobStatus::Skipped, + ); + }) + .ok(); continue; } (job.job)(state.clone(), cx).await; @@ -7797,7 +7890,7 @@ impl Repository { repo_path: RepoPath, cx: &App, ) -> Task>> { - let rx = self.send_job(None, move |state, _| async move { + let rx = self.send_job("load_staged_text", None, move |state, _| async move { match state { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { anyhow::Ok(backend.load_index_text(repo_path).await) @@ -7822,7 +7915,7 @@ impl Repository { repo_path: RepoPath, cx: &App, ) -> Task> { - let rx = self.send_job(None, move |state, _| async move { + let rx = self.send_job("load_committed_text", None, move |state, _| async move { match state { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { let committed_text = backend.load_committed_text(repo_path.clone()).await; @@ -7865,19 +7958,23 @@ impl Repository { pub fn load_commit_template_text( &mut self, ) -> oneshot::Receiver>> { - self.send_job(None, move |git_repo, _cx| async move { - match git_repo { - RepositoryState::Local(LocalRepositoryState { backend, .. }) => { - backend.load_commit_template().await + self.send_job( + "load_commit_template_text", + None, + move |git_repo, _cx| async move { + match git_repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.load_commit_template().await + } + RepositoryState::Remote(_) => Ok(None), } - RepositoryState::Remote(_) => Ok(None), - } - }) + }, + ) } fn load_blob_content(&mut self, oid: Oid, cx: &App) -> Task> { let repository_id = self.snapshot.id; - let rx = self.send_job(None, move |state, _| async move { + let rx = self.send_job("load_blob_content", None, move |state, _| async move { match state { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.load_blob_content(oid).await @@ -7909,6 +8006,7 @@ impl Repository { let this = cx.weak_entity(); let _ = self.send_keyed_job( + "paths_changed", Some(GitJobKey::RefreshStatuses), None, |state, mut cx| async move { @@ -8015,8 +8113,12 @@ impl Repository { self.active_jobs.values().next().cloned() } + pub fn job_debug_queue(&self) -> &job_debug_queue::GitJobDebugQueue { + &self.job_debug_queue + } + pub fn barrier(&mut self) -> oneshot::Receiver<()> { - self.send_job(None, |_, _| async {}) + self.send_job("barrier", None, |_, _| async {}) } fn spawn_job_with_tracking( @@ -8086,7 +8188,7 @@ impl Repository { } pub fn access(&mut self, _cx: &App) -> oneshot::Receiver { - self.send_job(None, move |git_repo, _cx| async move { + self.send_job("access", None, move |git_repo, _cx| async move { match git_repo { // TODO: Correctly handle remote repositories, where the user // that's running the Zed remote may not own the `.git/` @@ -8108,6 +8210,24 @@ impl Repository { } } +fn format_job_key(key: &GitJobKey) -> SharedString { + match key { + GitJobKey::WriteIndex(paths) => { + let paths_str: Vec<_> = paths + .iter() + .map(|p| { + let rel: &RelPath = p; + format!("{}", AsRef::::as_ref(rel).display()) + }) + .collect(); + format!("WriteIndex({})", paths_str.join(", ")).into() + } + GitJobKey::ReloadBufferDiffBases => "ReloadBufferDiffBases".into(), + GitJobKey::RefreshStatuses => "RefreshStatuses".into(), + GitJobKey::ReloadGitState => "ReloadGitState".into(), + } +} + /// If `path` is a git linked worktree checkout, resolves it to the main /// repository's identity path. For regular linked worktrees this is the main /// repository's working directory; for linked worktrees backed by a bare repo diff --git a/crates/project/src/git_store/job_debug_queue.rs b/crates/project/src/git_store/job_debug_queue.rs new file mode 100644 index 00000000000000..c204451d58b406 --- /dev/null +++ b/crates/project/src/git_store/job_debug_queue.rs @@ -0,0 +1,222 @@ +use std::{collections::VecDeque, time::Instant}; + +use gpui::SharedString; + +use super::JobId; + +pub struct GitJobDebugQueue { + pending: VecDeque, + running: VecDeque, + completed: VecDeque, +} + +const MAX_COMPLETED_JOBS: usize = 500; + +#[derive(Clone, Debug)] +pub struct PendingJob { + pub id: JobId, + pub description: SharedString, + pub key: Option, + pub enqueued_at: Instant, +} + +#[derive(Clone, Debug)] +pub struct RunningJob { + pub id: JobId, + pub description: SharedString, + pub key: Option, + pub enqueued_at: Instant, + pub started_at: Instant, +} + +#[derive(Clone, Debug)] +pub struct CompletedJob { + pub id: JobId, + pub description: SharedString, + pub key: Option, + pub enqueued_at: Instant, + pub started_at: Option, + pub completed_at: Instant, + pub status: CompletedJobStatus, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CompletedJobStatus { + Finished, + Skipped, +} + +impl GitJobDebugQueue { + pub fn new() -> Self { + Self { + pending: VecDeque::new(), + running: VecDeque::new(), + completed: VecDeque::new(), + } + } + + pub fn add(&mut self, id: JobId, description: &'static str, key: Option) { + self.pending.push_back(PendingJob { + id, + description: description.into(), + key, + enqueued_at: Instant::now(), + }); + } + + pub fn mark_running(&mut self, id: JobId) { + let Some(index) = self.pending.iter().position(|job| job.id == id) else { + return; + }; + // Safe to unwrap: `index` was just found by `position()`, so it's in bounds. + let pending = self.pending.remove(index).unwrap(); + + self.running.push_back(RunningJob { + id: pending.id, + description: pending.description, + key: pending.key, + enqueued_at: pending.enqueued_at, + started_at: Instant::now(), + }); + } + + pub fn mark_complete(&mut self, id: JobId, status: CompletedJobStatus) { + let (enqueued_at, started_at, description, key) = + if let Some(index) = self.running.iter().position(|job| job.id == id) { + let running = self.running.remove(index).unwrap(); + ( + running.enqueued_at, + Some(running.started_at), + running.description, + running.key, + ) + } else if let Some(index) = self.pending.iter().position(|job| job.id == id) { + let pending = self.pending.remove(index).unwrap(); + (pending.enqueued_at, None, pending.description, pending.key) + } else { + return; + }; + + self.completed.push_back(CompletedJob { + id, + description, + key, + enqueued_at, + started_at, + completed_at: Instant::now(), + status, + }); + + while self.completed.len() > MAX_COMPLETED_JOBS { + self.completed.pop_front(); + } + } + + pub fn to_debug_string(&self) -> String { + let mut entries = Vec::new(); + + let mut pending_count = 0u64; + let mut running_count = 0u64; + let mut finished_count = 0u64; + let mut skipped_count = 0u64; + + for job in &self.pending { + pending_count += 1; + entries.push((job.enqueued_at, self.format_pending(job))); + } + for job in &self.running { + running_count += 1; + entries.push((job.enqueued_at, self.format_running(job))); + } + for job in &self.completed { + match job.status { + CompletedJobStatus::Finished => finished_count += 1, + CompletedJobStatus::Skipped => skipped_count += 1, + } + entries.push((job.enqueued_at, self.format_completed(job))); + } + + entries.sort_by_key(|(enqueued_at, _)| *enqueued_at); + + let json_entries: Vec = + entries.into_iter().map(|(_, json)| json).collect(); + + let json = serde_json::json!({ + "summary": { + "pending": pending_count, + "running": running_count, + "finished": finished_count, + "skipped": skipped_count, + }, + "entries": json_entries, + }); + + serde_json::to_string_pretty(&json).unwrap_or_default() + } + + fn format_pending(&self, job: &PendingJob) -> serde_json::Value { + serde_json::json!({ + "id": job.id, + "description": job.description.as_ref(), + "key": job.key.as_ref().map(|k| k.as_ref()), + "status": "Pending", + "enqueued": format!("{} ago", format_duration(job.enqueued_at.elapsed())), + }) + } + + fn format_running(&self, job: &RunningJob) -> serde_json::Value { + serde_json::json!({ + "id": job.id, + "description": job.description.as_ref(), + "key": job.key.as_ref().map(|k| k.as_ref()), + "status": "Running", + "enqueued": format!("{} ago", format_duration(job.enqueued_at.elapsed())), + "wait_time": format_duration(job.started_at.duration_since(job.enqueued_at)), + "run_time": format!("{} (still running)", format_duration(job.started_at.elapsed())), + }) + } + + fn format_completed(&self, job: &CompletedJob) -> serde_json::Value { + let status = match job.status { + CompletedJobStatus::Finished => "Finished", + CompletedJobStatus::Skipped => "Skipped", + }; + + let (wait_time, run_time) = if let Some(started) = job.started_at { + let wait = format_duration(started.duration_since(job.enqueued_at)); + let run = format_duration(job.completed_at.duration_since(started)); + (wait, Some(run)) + } else { + let wait = format!( + "{} (skipped)", + format_duration(job.completed_at.duration_since(job.enqueued_at)) + ); + (wait, None) + }; + + serde_json::json!({ + "id": job.id, + "description": job.description.as_ref(), + "key": job.key.as_ref().map(|k| k.as_ref()), + "status": status, + "enqueued": format!("{} ago", format_duration(job.enqueued_at.elapsed())), + "wait_time": wait_time, + "run_time": run_time, + }) + } +} + +fn format_duration(duration: std::time::Duration) -> String { + let secs = duration.as_secs_f64(); + if secs < 0.001 { + format!("{:.0}us", secs * 1_000_000.0) + } else if secs < 1.0 { + format!("{:.0}ms", secs * 1000.0) + } else if secs < 60.0 { + format!("{:.0}s", secs) + } else if secs < 3600.0 { + format!("{:.0}m", secs / 60.0) + } else { + format!("{:.0}h", secs / 3600.0) + } +} diff --git a/crates/project/src/telemetry_snapshot.rs b/crates/project/src/telemetry_snapshot.rs index 6212b448835350..1cd7bd75614a08 100644 --- a/crates/project/src/telemetry_snapshot.rs +++ b/crates/project/src/telemetry_snapshot.rs @@ -77,7 +77,7 @@ impl TelemetryWorktreeSnapshot { repo.update(cx, |repo, _| { let current_branch = repo.branch.as_ref().map(|branch| branch.name().to_owned()); - repo.send_job(None, |state, _| async move { + repo.send_job("telemetry_snapshot", None, |state, _| async move { let RepositoryState::Local(LocalRepositoryState { backend, .. }) = state else { From 147524879e328513b0883017733462b03b27cf02 Mon Sep 17 00:00:00 2001 From: Mikhail Pertsev Date: Thu, 7 May 2026 18:14:47 +0200 Subject: [PATCH 23/33] editor: Extract `fold` and `selection` out of `editor.rs` (#56070) cc @SomeoneToIgnore ## Summary Follow-up to #56030 This mechanically extracts two editor topics into focused sibling modules: - `crates/editor/src/fold.rs` - `crates/editor/src/selection.rs` One odd boundary remains: several selection state types still live in `editor.rs`. I didn't move them because those caused that "huge 11k diff" in the previous PR, so I propose to move them later. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/editor/src/editor.rs | 2266 +++----------------------------- crates/editor/src/fold.rs | 1095 +++++++++++++++ crates/editor/src/selection.rs | 899 +++++++++++++ 3 files changed, 2143 insertions(+), 2117 deletions(-) create mode 100644 crates/editor/src/fold.rs create mode 100644 crates/editor/src/selection.rs diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 175b430ff014cf..608895da9c984a 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -22,6 +22,7 @@ mod document_colors; mod document_symbols; mod editor_settings; mod element; +mod fold; mod folding_ranges; mod git; mod highlight_matching_bracket; @@ -62,6 +63,7 @@ mod completions; mod config; mod diagnostics; mod rewrap; +mod selection; pub(crate) use actions::*; pub use code_actions::CodeActionProvider; @@ -1444,13 +1446,6 @@ impl GutterDimensions { pub fn full_width(&self) -> Pixels { self.margin + self.width } - - /// The width of the space reserved for the fold indicators, - /// use alongside 'justify_end' and `gutter_width` to - /// right align content with the line numbers - pub fn fold_area_width(&self) -> Pixels { - self.margin + self.right_padding - } } struct CharacterDimensions { @@ -2784,30 +2779,6 @@ impl Editor { .is_some_and(|menu| menu.context_menu.focus_handle(cx).is_focused(window)) } - pub fn is_range_selected(&mut self, range: &Range, cx: &mut Context) -> bool { - if self - .selections - .pending_anchor() - .is_some_and(|pending_selection| { - let snapshot = self.buffer().read(cx).snapshot(cx); - pending_selection.range().includes(range, &snapshot) - }) - { - return true; - } - - self.selections - .disjoint_in_range::(range.clone(), &self.display_snapshot(cx)) - .into_iter() - .any(|selection| { - // This is needed to cover a corner case, if we just check for an existing - // selection in the fold range, having a cursor at the start of the fold - // marks it as selected. Non-empty selections don't cause this. - let length = selection.end - selection.start; - length > 0 - }) - } - pub fn key_context(&self, window: &mut Window, cx: &mut App) -> KeyContext { self.key_context_internal(self.has_active_edit_prediction(), window, cx) } @@ -3621,449 +3592,6 @@ impl Editor { self.use_modal_editing } - fn selections_did_change( - &mut self, - local: bool, - old_cursor_position: &Anchor, - effects: SelectionEffects, - window: &mut Window, - cx: &mut Context, - ) { - self.last_selection_from_search = effects.from_search; - window.invalidate_character_coordinates(); - - // Copy selections to primary selection buffer - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - if local { - let selections = self - .selections - .all::(&self.display_snapshot(cx)); - let buffer_handle = self.buffer.read(cx).read(cx); - - let mut text = String::new(); - for (index, selection) in selections.iter().enumerate() { - let text_for_selection = buffer_handle - .text_for_range(selection.start..selection.end) - .collect::(); - - text.push_str(&text_for_selection); - if index != selections.len() - 1 { - text.push('\n'); - } - } - - if !text.is_empty() { - cx.write_to_primary(ClipboardItem::new_string(text)); - } - } - - let selection_anchors = self.selections.disjoint_anchors_arc(); - - if self.focus_handle.is_focused(window) && self.leader_id.is_none() { - self.buffer.update(cx, |buffer, cx| { - buffer.set_active_selections( - &selection_anchors, - self.selections.line_mode(), - self.cursor_shape, - cx, - ) - }); - } - let display_map = self - .display_map - .update(cx, |display_map, cx| display_map.snapshot(cx)); - let buffer = display_map.buffer_snapshot(); - if self.selections.count() == 1 { - self.add_selections_state = None; - } - self.select_next_state = None; - self.select_prev_state = None; - self.select_syntax_node_history.try_clear(); - self.invalidate_autoclose_regions(&selection_anchors, buffer); - self.snippet_stack.invalidate(&selection_anchors, buffer); - self.take_rename(false, window, cx); - - let newest_selection = self.selections.newest_anchor(); - let new_cursor_position = newest_selection.head(); - let selection_start = newest_selection.start; - - if effects.nav_history.is_none() || effects.nav_history == Some(true) { - self.push_to_nav_history( - *old_cursor_position, - Some(new_cursor_position.to_point(buffer)), - false, - effects.nav_history == Some(true), - cx, - ); - } - - if local { - if let Some((anchor, _)) = buffer.anchor_to_buffer_anchor(new_cursor_position) { - self.register_buffer(anchor.buffer_id, cx); - } - - let mut context_menu = self.context_menu.borrow_mut(); - let completion_menu = match context_menu.as_ref() { - Some(CodeContextMenu::Completions(menu)) => Some(menu), - Some(CodeContextMenu::CodeActions(_)) => { - *context_menu = None; - None - } - None => None, - }; - let completion_position = completion_menu.map(|menu| menu.initial_position); - drop(context_menu); - - if effects.completions - && let Some(completion_position) = completion_position - { - let start_offset = selection_start.to_offset(buffer); - let position_matches = start_offset == completion_position.to_offset(buffer); - let continue_showing = if let Some((snap, ..)) = - buffer.point_to_buffer_offset(completion_position) - && !snap.capability.editable() - { - false - } else if position_matches { - if self.snippet_stack.is_empty() { - buffer.char_kind_before(start_offset, Some(CharScopeContext::Completion)) - == Some(CharKind::Word) - } else { - // Snippet choices can be shown even when the cursor is in whitespace. - // Dismissing the menu with actions like backspace is handled by - // invalidation regions. - true - } - } else { - false - }; - - if continue_showing { - self.open_or_update_completions_menu(None, None, false, window, cx); - } else { - self.hide_context_menu(window, cx); - } - } - - hide_hover(self, cx); - - self.refresh_code_actions_for_selection(window, cx); - self.refresh_document_highlights(cx); - refresh_linked_ranges(self, window, cx); - - self.refresh_selected_text_highlights(&display_map, false, window, cx); - self.refresh_matching_bracket_highlights(&display_map, cx); - self.refresh_outline_symbols_at_cursor(cx); - self.update_visible_edit_prediction(window, cx); - self.hide_blame_popover(true, cx); - if self.git_blame_inline_enabled { - self.start_inline_blame_timer(window, cx); - } - } - - self.blink_manager.update(cx, BlinkManager::pause_blinking); - - if local && !self.suppress_selection_callback { - if let Some(callback) = self.on_local_selections_changed.as_ref() { - let cursor_position = self.selections.newest::(&display_map).head(); - callback(cursor_position, window, cx); - } - } - - cx.emit(EditorEvent::SelectionsChanged { local }); - - let selections = &self.selections.disjoint_anchors_arc(); - if local && let Some(buffer_snapshot) = buffer.as_singleton() { - let inmemory_selections = selections - .iter() - .map(|s| { - let start = s.range().start.text_anchor_in(buffer_snapshot); - let end = s.range().end.text_anchor_in(buffer_snapshot); - (start..end).to_point(buffer_snapshot) - }) - .collect(); - self.update_restoration_data(cx, |data| { - data.selections = inmemory_selections; - }); - - if WorkspaceSettings::get(None, cx).restore_on_startup - != RestoreOnStartupBehavior::EmptyTab - && let Some(workspace_id) = self.workspace_serialization_id(cx) - { - let snapshot = self.buffer().read(cx).snapshot(cx); - let selections = selections.clone(); - let background_executor = cx.background_executor().clone(); - let editor_id = cx.entity().entity_id().as_u64() as ItemId; - let db = EditorDb::global(cx); - self.serialize_selections = cx.background_spawn(async move { - background_executor.timer(SERIALIZATION_THROTTLE_TIME).await; - let db_selections = selections - .iter() - .map(|selection| { - ( - selection.start.to_offset(&snapshot).0, - selection.end.to_offset(&snapshot).0, - ) - }) - .collect(); - - db.save_editor_selections(editor_id, workspace_id, db_selections) - .await - .with_context(|| { - format!( - "persisting editor selections for editor {editor_id}, \ - workspace {workspace_id:?}" - ) - }) - .log_err(); - }); - } - } - - cx.notify(); - } - - fn folds_did_change(&mut self, cx: &mut Context) { - use text::ToOffset as _; - - if self.mode.is_minimap() - || WorkspaceSettings::get(None, cx).restore_on_startup - == RestoreOnStartupBehavior::EmptyTab - { - return; - } - - let display_snapshot = self - .display_map - .update(cx, |display_map, cx| display_map.snapshot(cx)); - let Some(buffer_snapshot) = display_snapshot.buffer_snapshot().as_singleton() else { - return; - }; - let inmemory_folds = display_snapshot - .folds_in_range(MultiBufferOffset(0)..display_snapshot.buffer_snapshot().len()) - .map(|fold| { - let start = fold.range.start.text_anchor_in(buffer_snapshot); - let end = fold.range.end.text_anchor_in(buffer_snapshot); - (start..end).to_point(buffer_snapshot) - }) - .collect(); - self.update_restoration_data(cx, |data| { - data.folds = inmemory_folds; - }); - - let Some(workspace_id) = self.workspace_serialization_id(cx) else { - return; - }; - - // Get file path for path-based fold storage (survives tab close) - let Some(file_path) = self.buffer().read(cx).as_singleton().and_then(|buffer| { - project::File::from_dyn(buffer.read(cx).file()) - .map(|file| Arc::::from(file.abs_path(cx))) - }) else { - return; - }; - - let background_executor = cx.background_executor().clone(); - const FINGERPRINT_LEN: usize = 32; - let db_folds = display_snapshot - .folds_in_range(MultiBufferOffset(0)..display_snapshot.buffer_snapshot().len()) - .map(|fold| { - let start = fold - .range - .start - .text_anchor_in(buffer_snapshot) - .to_offset(buffer_snapshot); - let end = fold - .range - .end - .text_anchor_in(buffer_snapshot) - .to_offset(buffer_snapshot); - - // Extract fingerprints - content at fold boundaries for validation on restore - // Both fingerprints must be INSIDE the fold to avoid capturing surrounding - // content that might change independently. - // start_fp: first min(32, fold_len) bytes of fold content - // end_fp: last min(32, fold_len) bytes of fold content - // Clip to character boundaries to handle multibyte UTF-8 characters. - let fold_len = end - start; - let start_fp_end = buffer_snapshot - .clip_offset(start + std::cmp::min(FINGERPRINT_LEN, fold_len), Bias::Left); - let start_fp: String = buffer_snapshot - .text_for_range(start..start_fp_end) - .collect(); - let end_fp_start = buffer_snapshot - .clip_offset(end.saturating_sub(FINGERPRINT_LEN).max(start), Bias::Right); - let end_fp: String = buffer_snapshot.text_for_range(end_fp_start..end).collect(); - - (start, end, start_fp, end_fp) - }) - .collect::>(); - let db = EditorDb::global(cx); - self.serialize_folds = cx.background_spawn(async move { - background_executor.timer(SERIALIZATION_THROTTLE_TIME).await; - if db_folds.is_empty() { - // No folds - delete any persisted folds for this file - db.delete_file_folds(workspace_id, file_path) - .await - .with_context(|| format!("deleting file folds for workspace {workspace_id:?}")) - .log_err(); - } else { - db.save_file_folds(workspace_id, file_path, db_folds) - .await - .with_context(|| { - format!("persisting file folds for workspace {workspace_id:?}") - }) - .log_err(); - } - }); - } - - pub fn sync_selections( - &mut self, - other: Entity, - cx: &mut Context, - ) -> gpui::Subscription { - let other_selections = other.read(cx).selections.disjoint_anchors().to_vec(); - if !other_selections.is_empty() { - self.selections - .change_with(&self.display_snapshot(cx), |selections| { - selections.select_anchors(other_selections); - }); - } - - let other_subscription = cx.subscribe(&other, |this, other, other_evt, cx| { - if let EditorEvent::SelectionsChanged { local: true } = other_evt { - let other_selections = other.read(cx).selections.disjoint_anchors().to_vec(); - if other_selections.is_empty() { - return; - } - let snapshot = this.display_snapshot(cx); - this.selections.change_with(&snapshot, |selections| { - selections.select_anchors(other_selections); - }); - } - }); - - let this_subscription = cx.subscribe_self::(move |this, this_evt, cx| { - if let EditorEvent::SelectionsChanged { local: true } = this_evt { - let these_selections = this.selections.disjoint_anchors().to_vec(); - if these_selections.is_empty() { - return; - } - other.update(cx, |other_editor, cx| { - let snapshot = other_editor.display_snapshot(cx); - other_editor - .selections - .change_with(&snapshot, |selections| { - selections.select_anchors(these_selections); - }) - }); - } - }); - - Subscription::join(other_subscription, this_subscription) - } - - fn unfold_buffers_with_selections(&mut self, cx: &mut Context) { - if self.buffer().read(cx).is_singleton() { - return; - } - let snapshot = self.buffer.read(cx).snapshot(cx); - let buffer_ids: HashSet = self - .selections - .disjoint_anchor_ranges() - .flat_map(|range| snapshot.buffer_ids_for_range(range)) - .collect(); - for buffer_id in buffer_ids { - self.unfold_buffer(buffer_id, cx); - } - } - - /// Changes selections using the provided mutation function. Changes to `self.selections` occur - /// immediately, but when run within `transact` or `with_selection_effects_deferred` other - /// effects of selection change occur at the end of the transaction. - pub fn change_selections( - &mut self, - effects: SelectionEffects, - window: &mut Window, - cx: &mut Context, - change: impl FnOnce(&mut MutableSelectionsCollection<'_, '_>) -> R, - ) -> R { - let snapshot = self.display_snapshot(cx); - if let Some(state) = &mut self.deferred_selection_effects_state { - state.effects.scroll = effects.scroll.or(state.effects.scroll); - state.effects.completions = effects.completions; - state.effects.nav_history = effects.nav_history.or(state.effects.nav_history); - let (changed, result) = self.selections.change_with(&snapshot, change); - state.changed |= changed; - return result; - } - let mut state = DeferredSelectionEffectsState { - changed: false, - effects, - old_cursor_position: self.selections.newest_anchor().head(), - history_entry: SelectionHistoryEntry { - selections: self.selections.disjoint_anchors_arc(), - select_next_state: self.select_next_state.clone(), - select_prev_state: self.select_prev_state.clone(), - add_selections_state: self.add_selections_state.clone(), - }, - }; - let (changed, result) = self.selections.change_with(&snapshot, change); - state.changed = state.changed || changed; - if self.defer_selection_effects { - self.deferred_selection_effects_state = Some(state); - } else { - self.apply_selection_effects(state, window, cx); - } - result - } - - /// Defers the effects of selection change, so that the effects of multiple calls to - /// `change_selections` are applied at the end. This way these intermediate states aren't added - /// to selection history and the state of popovers based on selection position aren't - /// erroneously updated. - pub fn with_selection_effects_deferred( - &mut self, - window: &mut Window, - cx: &mut Context, - update: impl FnOnce(&mut Self, &mut Window, &mut Context) -> R, - ) -> R { - let already_deferred = self.defer_selection_effects; - self.defer_selection_effects = true; - let result = update(self, window, cx); - if !already_deferred { - self.defer_selection_effects = false; - if let Some(state) = self.deferred_selection_effects_state.take() { - self.apply_selection_effects(state, window, cx); - } - } - result - } - - fn apply_selection_effects( - &mut self, - state: DeferredSelectionEffectsState, - window: &mut Window, - cx: &mut Context, - ) { - if state.changed { - self.selection_history.push(state.history_entry); - - if let Some(autoscroll) = state.effects.scroll { - self.request_autoscroll(autoscroll, cx); - } - - let old_cursor_position = &state.old_cursor_position; - - self.selections_did_change(true, old_cursor_position, state.effects, window, cx); - - if self.should_open_signature_help_automatically(old_cursor_position, cx) { - self.show_signature_help_auto(window, cx); - } - } - } - pub fn edit(&mut self, edits: I, cx: &mut Context) where I: IntoIterator, T)>, @@ -4118,515 +3646,41 @@ impl Editor { }); } - fn select(&mut self, phase: SelectPhase, window: &mut Window, cx: &mut Context) { - self.hide_context_menu(window, cx); + pub fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { + self.selection_mark_mode = false; + self.selection_drag_state = SelectionDragState::None; - match phase { - SelectPhase::Begin { - position, - add, - click_count, - } => self.begin_selection(position, add, click_count, window, cx), - SelectPhase::BeginColumnar { - position, - goal_column, - reset, - mode, - } => self.begin_columnar_selection(position, goal_column, reset, mode, window, cx), - SelectPhase::Extend { - position, - click_count, - } => self.extend_selection(position, click_count, window, cx), - SelectPhase::Update { - position, - goal_column, - scroll_delta, - } => self.update_selection(position, goal_column, scroll_delta, window, cx), - SelectPhase::End => self.end_selection(window, cx), + if self.dismiss_menus_and_popups(true, window, cx) { + cx.notify(); + return; } - } - - fn extend_selection( - &mut self, - position: DisplayPoint, - click_count: usize, - window: &mut Window, - cx: &mut Context, - ) { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let tail = self - .selections - .newest::(&display_map) - .tail(); - let click_count = click_count.max(match self.selections.select_mode() { - SelectMode::Character => 1, - SelectMode::Word(_) => 2, - SelectMode::Line(_) => 3, - SelectMode::All => 4, - }); - self.begin_selection(position, false, click_count, window, cx); - - let tail_anchor = display_map.buffer_snapshot().anchor_before(tail); - - let current_selection = match self.selections.select_mode() { - SelectMode::Character | SelectMode::All => tail_anchor..tail_anchor, - SelectMode::Word(range) | SelectMode::Line(range) => range.clone(), - }; - - let mut pending_selection = self - .selections - .pending_anchor() - .cloned() - .expect("extend_selection not called with pending selection"); - - if pending_selection - .start - .cmp(¤t_selection.start, display_map.buffer_snapshot()) - == Ordering::Greater - { - pending_selection.start = current_selection.start; + if self.clear_expanded_diff_hunks(cx) { + cx.notify(); + return; } - if pending_selection - .end - .cmp(¤t_selection.end, display_map.buffer_snapshot()) - == Ordering::Less - { - pending_selection.end = current_selection.end; - pending_selection.reversed = true; + if self.show_git_blame_gutter { + self.show_git_blame_gutter = false; + cx.notify(); + return; } - let mut pending_mode = self.selections.pending_mode().unwrap(); - match &mut pending_mode { - SelectMode::Word(range) | SelectMode::Line(range) => *range = current_selection, - _ => {} + if self.mode.is_full() + && self.change_selections(Default::default(), window, cx, |s| s.try_cancel()) + { + cx.notify(); + return; } - let effects = if EditorSettings::get_global(cx).autoscroll_on_clicks { - SelectionEffects::scroll(Autoscroll::fit()) - } else { - SelectionEffects::no_scroll() - }; - - self.change_selections(effects, window, cx, |s| { - s.set_pending(pending_selection.clone(), pending_mode); - s.set_is_extending(true); - }); + cx.propagate(); } - fn begin_selection( + pub fn dismiss_menus_and_popups( &mut self, - position: DisplayPoint, - add: bool, - click_count: usize, + is_user_requested: bool, window: &mut Window, cx: &mut Context, - ) { - if !self.focus_handle.is_focused(window) { - self.last_focused_descendant = None; - window.focus(&self.focus_handle, cx); - } - - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let buffer = display_map.buffer_snapshot(); - let position = display_map.clip_point(position, Bias::Left); - - let start; - let end; - let mode; - let mut auto_scroll; - match click_count { - 1 => { - start = buffer.anchor_before(position.to_point(&display_map)); - end = start; - mode = SelectMode::Character; - auto_scroll = true; - } - 2 => { - let position = display_map - .clip_point(position, Bias::Left) - .to_offset(&display_map, Bias::Left); - let (range, _) = buffer.surrounding_word(position, None); - start = buffer.anchor_before(range.start); - end = buffer.anchor_before(range.end); - mode = SelectMode::Word(start..end); - auto_scroll = true; - } - 3 => { - let position = display_map - .clip_point(position, Bias::Left) - .to_point(&display_map); - let line_start = display_map.prev_line_boundary(position).0; - let next_line_start = buffer.clip_point( - display_map.next_line_boundary(position).0 + Point::new(1, 0), - Bias::Left, - ); - start = buffer.anchor_before(line_start); - end = buffer.anchor_before(next_line_start); - mode = SelectMode::Line(start..end); - auto_scroll = true; - } - _ => { - start = buffer.anchor_before(MultiBufferOffset(0)); - end = buffer.anchor_before(buffer.len()); - mode = SelectMode::All; - auto_scroll = false; - } - } - auto_scroll &= EditorSettings::get_global(cx).autoscroll_on_clicks; - - let point_to_delete: Option = { - let selected_points: Vec> = - self.selections.disjoint_in_range(start..end, &display_map); - - if !add || click_count > 1 { - None - } else if !selected_points.is_empty() { - Some(selected_points[0].id) - } else { - let clicked_point_already_selected = - self.selections.disjoint_anchors().iter().find(|selection| { - selection.start.to_point(buffer) == start.to_point(buffer) - || selection.end.to_point(buffer) == end.to_point(buffer) - }); - - clicked_point_already_selected.map(|selection| selection.id) - } - }; - - let selections_count = self.selections.count(); - let effects = if auto_scroll { - SelectionEffects::default() - } else { - SelectionEffects::no_scroll() - }; - - self.change_selections(effects, window, cx, |s| { - if let Some(point_to_delete) = point_to_delete { - s.delete(point_to_delete); - - if selections_count == 1 { - s.set_pending_anchor_range(start..end, mode); - } - } else { - if !add { - s.clear_disjoint(); - } - - s.set_pending_anchor_range(start..end, mode); - } - }); - } - - fn begin_columnar_selection( - &mut self, - position: DisplayPoint, - goal_column: u32, - reset: bool, - mode: ColumnarMode, - window: &mut Window, - cx: &mut Context, - ) { - if !self.focus_handle.is_focused(window) { - self.last_focused_descendant = None; - window.focus(&self.focus_handle, cx); - } - - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - - if reset { - let pointer_position = display_map - .buffer_snapshot() - .anchor_before(position.to_point(&display_map)); - - self.change_selections( - SelectionEffects::scroll(Autoscroll::newest()), - window, - cx, - |s| { - s.clear_disjoint(); - s.set_pending_anchor_range( - pointer_position..pointer_position, - SelectMode::Character, - ); - }, - ); - }; - - let tail = self.selections.newest::(&display_map).tail(); - let selection_anchor = display_map.buffer_snapshot().anchor_before(tail); - self.columnar_selection_state = match mode { - ColumnarMode::FromMouse => Some(ColumnarSelectionState::FromMouse { - selection_tail: selection_anchor, - display_point: if reset { - if position.column() != goal_column { - Some(DisplayPoint::new(position.row(), goal_column)) - } else { - None - } - } else { - None - }, - }), - ColumnarMode::FromSelection => Some(ColumnarSelectionState::FromSelection { - selection_tail: selection_anchor, - }), - }; - - if !reset { - self.select_columns(position, goal_column, &display_map, window, cx); - } - } - - fn update_selection( - &mut self, - position: DisplayPoint, - goal_column: u32, - scroll_delta: gpui::Point, - window: &mut Window, - cx: &mut Context, - ) { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - - if self.columnar_selection_state.is_some() { - self.select_columns(position, goal_column, &display_map, window, cx); - } else if let Some(mut pending) = self.selections.pending_anchor().cloned() { - let buffer = display_map.buffer_snapshot(); - let head; - let tail; - let mode = self.selections.pending_mode().unwrap(); - match &mode { - SelectMode::Character => { - head = position.to_point(&display_map); - tail = pending.tail().to_point(buffer); - } - SelectMode::Word(original_range) => { - let offset = display_map - .clip_point(position, Bias::Left) - .to_offset(&display_map, Bias::Left); - let original_range = original_range.to_offset(buffer); - - let head_offset = if buffer.is_inside_word(offset, None) - || original_range.contains(&offset) - { - let (word_range, _) = buffer.surrounding_word(offset, None); - if word_range.start < original_range.start { - word_range.start - } else { - word_range.end - } - } else { - offset - }; - - head = head_offset.to_point(buffer); - if head_offset <= original_range.start { - tail = original_range.end.to_point(buffer); - } else { - tail = original_range.start.to_point(buffer); - } - } - SelectMode::Line(original_range) => { - let original_range = original_range.to_point(display_map.buffer_snapshot()); - - let position = display_map - .clip_point(position, Bias::Left) - .to_point(&display_map); - let line_start = display_map.prev_line_boundary(position).0; - let next_line_start = buffer.clip_point( - display_map.next_line_boundary(position).0 + Point::new(1, 0), - Bias::Left, - ); - - if line_start < original_range.start { - head = line_start - } else { - head = next_line_start - } - - if head <= original_range.start { - tail = original_range.end; - } else { - tail = original_range.start; - } - } - SelectMode::All => { - return; - } - }; - - if head < tail { - pending.start = buffer.anchor_before(head); - pending.end = buffer.anchor_before(tail); - pending.reversed = true; - } else { - pending.start = buffer.anchor_before(tail); - pending.end = buffer.anchor_before(head); - pending.reversed = false; - } - - self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.set_pending(pending.clone(), mode); - }); - } else { - log::error!("update_selection dispatched with no pending selection"); - return; - } - - self.apply_scroll_delta(scroll_delta, window, cx); - cx.notify(); - } - - fn end_selection(&mut self, window: &mut Window, cx: &mut Context) { - self.columnar_selection_state.take(); - if let Some(pending_mode) = self.selections.pending_mode() { - let selections = self - .selections - .all::(&self.display_snapshot(cx)); - self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select(selections); - s.clear_pending(); - if s.is_extending() { - s.set_is_extending(false); - } else { - s.set_select_mode(pending_mode); - } - }); - } - } - - fn select_columns( - &mut self, - head: DisplayPoint, - goal_column: u32, - display_map: &DisplaySnapshot, - window: &mut Window, - cx: &mut Context, - ) { - let Some(columnar_state) = self.columnar_selection_state.as_ref() else { - return; - }; - - let tail = match columnar_state { - ColumnarSelectionState::FromMouse { - selection_tail, - display_point, - } => display_point.unwrap_or_else(|| selection_tail.to_display_point(display_map)), - ColumnarSelectionState::FromSelection { selection_tail } => { - selection_tail.to_display_point(display_map) - } - }; - - let start_row = cmp::min(tail.row(), head.row()); - let end_row = cmp::max(tail.row(), head.row()); - let start_column = cmp::min(tail.column(), goal_column); - let end_column = cmp::max(tail.column(), goal_column); - let reversed = start_column < tail.column(); - - let selection_ranges = (start_row.0..=end_row.0) - .map(DisplayRow) - .filter_map(|row| { - if (matches!(columnar_state, ColumnarSelectionState::FromMouse { .. }) - || start_column <= display_map.line_len(row)) - && !display_map.is_block_line(row) - { - let start = display_map - .clip_point(DisplayPoint::new(row, start_column), Bias::Left) - .to_point(display_map); - let end = display_map - .clip_point(DisplayPoint::new(row, end_column), Bias::Right) - .to_point(display_map); - if reversed { - Some(end..start) - } else { - Some(start..end) - } - } else { - None - } - }) - .collect::>(); - if selection_ranges.is_empty() { - return; - } - - let ranges = match columnar_state { - ColumnarSelectionState::FromMouse { .. } => { - let mut non_empty_ranges = selection_ranges - .iter() - .filter(|selection_range| selection_range.start != selection_range.end) - .peekable(); - if non_empty_ranges.peek().is_some() { - non_empty_ranges.cloned().collect() - } else { - selection_ranges - } - } - _ => selection_ranges, - }; - - self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges(ranges); - }); - cx.notify(); - } - - pub fn has_non_empty_selection(&self, snapshot: &DisplaySnapshot) -> bool { - self.selections - .all_adjusted(snapshot) - .iter() - .any(|selection| !selection.is_empty()) - } - - pub fn has_pending_nonempty_selection(&self) -> bool { - let pending_nonempty_selection = match self.selections.pending_anchor() { - Some(Selection { start, end, .. }) => start != end, - None => false, - }; - - pending_nonempty_selection - || (self.columnar_selection_state.is_some() - && self.selections.disjoint_anchors().len() > 1) - } - - pub fn has_pending_selection(&self) -> bool { - self.selections.pending_anchor().is_some() || self.columnar_selection_state.is_some() - } - - pub fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { - self.selection_mark_mode = false; - self.selection_drag_state = SelectionDragState::None; - - if self.dismiss_menus_and_popups(true, window, cx) { - cx.notify(); - return; - } - if self.clear_expanded_diff_hunks(cx) { - cx.notify(); - return; - } - if self.show_git_blame_gutter { - self.show_git_blame_gutter = false; - cx.notify(); - return; - } - - if self.mode.is_full() - && self.change_selections(Default::default(), window, cx, |s| s.try_cancel()) - { - cx.notify(); - return; - } - - cx.propagate(); - } - - pub fn dismiss_menus_and_popups( - &mut self, - is_user_requested: bool, - window: &mut Window, - cx: &mut Context, - ) -> bool { - let mut dismissed = false; + ) -> bool { + let mut dismissed = false; dismissed |= self.take_rename(false, window, cx).is_some(); dismissed |= self.hide_blame_popover(true, cx); @@ -6398,78 +5452,6 @@ impl Editor { }) } - fn refresh_single_line_folds(&mut self, window: &mut Window, cx: &mut Context) { - struct NewlineFold; - let type_id = std::any::TypeId::of::(); - if !self.mode.is_single_line() { - return; - } - let snapshot = self.snapshot(window, cx); - if snapshot.buffer_snapshot().max_point().row == 0 { - return; - } - let task = cx.background_spawn(async move { - let new_newlines = snapshot - .buffer_chars_at(MultiBufferOffset(0)) - .filter_map(|(c, i)| { - if c == '\n' { - Some( - snapshot.buffer_snapshot().anchor_after(i) - ..snapshot.buffer_snapshot().anchor_before(i + 1usize), - ) - } else { - None - } - }) - .collect::>(); - let existing_newlines = snapshot - .folds_in_range(MultiBufferOffset(0)..snapshot.buffer_snapshot().len()) - .filter_map(|fold| { - if fold.placeholder.type_tag == Some(type_id) { - Some(fold.range.start..fold.range.end) - } else { - None - } - }) - .collect::>(); - - (new_newlines, existing_newlines) - }); - self.folding_newlines = cx.spawn(async move |this, cx| { - let (new_newlines, existing_newlines) = task.await; - if new_newlines == existing_newlines { - return; - } - let placeholder = FoldPlaceholder { - render: Arc::new(move |_, _, cx| { - div() - .bg(cx.theme().status().hint_background) - .border_b_1() - .size_full() - .font(ThemeSettings::get_global(cx).buffer_font.clone()) - .border_color(cx.theme().status().hint) - .child("\\n") - .into_any() - }), - constrain_width: false, - merge_adjacent: false, - type_tag: Some(type_id), - collapsed_text: None, - }; - let creases = new_newlines - .into_iter() - .map(|range| Crease::simple(range, placeholder.clone())) - .collect(); - this.update(cx, |this, cx| { - this.display_map.update(cx, |display_map, cx| { - display_map.remove_folds_with_type(existing_newlines, type_id, cx); - display_map.fold(creases, cx); - }); - }) - .ok(); - }); - } - #[ztracing::instrument(skip_all)] fn refresh_outline_symbols_at_cursor(&mut self, cx: &mut Context) { if !self.lsp_data_enabled() { @@ -18190,921 +17172,167 @@ impl Editor { buffer.push_transaction(&transaction.0, cx); } cx.notify(); - }); - Ok(()) - }) - } - - pub fn restart_language_server( - &mut self, - _: &RestartLanguageServer, - _: &mut Window, - cx: &mut Context, - ) { - if let Some(project) = self.project.clone() { - self.buffer.update(cx, |multi_buffer, cx| { - project.update(cx, |project, cx| { - project.restart_language_servers_for_buffers( - multi_buffer.all_buffers().into_iter().collect(), - HashSet::default(), - cx, - ); - }); - }) - } - } - - pub fn stop_language_server( - &mut self, - _: &StopLanguageServer, - _: &mut Window, - cx: &mut Context, - ) { - if let Some(project) = self.project.clone() { - self.buffer.update(cx, |multi_buffer, cx| { - project.update(cx, |project, cx| { - project.stop_language_servers_for_buffers( - multi_buffer.all_buffers().into_iter().collect(), - HashSet::default(), - cx, - ); - }); - }); - } - } - - fn cancel_language_server_work( - workspace: &mut Workspace, - _: &actions::CancelLanguageServerWork, - _: &mut Window, - cx: &mut Context, - ) { - let project = workspace.project(); - let buffers = workspace - .active_item(cx) - .and_then(|item| item.act_as::(cx)) - .map_or(HashSet::default(), |editor| { - editor.read(cx).buffer.read(cx).all_buffers() - }); - project.update(cx, |project, cx| { - project.cancel_language_server_work_for_buffers(buffers, cx); - }); - } - - fn show_character_palette( - &mut self, - _: &ShowCharacterPalette, - window: &mut Window, - _: &mut Context, - ) { - window.show_character_palette(); - } - - pub fn toggle_minimap( - &mut self, - _: &ToggleMinimap, - window: &mut Window, - cx: &mut Context, - ) { - if self.supports_minimap(cx) { - self.set_minimap_visibility(self.minimap_visibility.toggle_visibility(), window, cx); - } - } - - pub fn set_selections_from_remote( - &mut self, - selections: Vec>, - pending_selection: Option>, - window: &mut Window, - cx: &mut Context, - ) { - let old_cursor_position = self.selections.newest_anchor().head(); - self.selections - .change_with(&self.display_snapshot(cx), |s| { - s.select_anchors(selections); - if let Some(pending_selection) = pending_selection { - s.set_pending(pending_selection, SelectMode::Character); - } else { - s.clear_pending(); - } - }); - self.selections_did_change( - false, - &old_cursor_position, - SelectionEffects::default(), - window, - cx, - ); - } - - pub fn transact( - &mut self, - window: &mut Window, - cx: &mut Context, - update: impl FnOnce(&mut Self, &mut Window, &mut Context), - ) -> Option { - self.with_selection_effects_deferred(window, cx, |this, window, cx| { - this.start_transaction_at(Instant::now(), window, cx); - update(this, window, cx); - this.end_transaction_at(Instant::now(), cx) - }) - } - - pub fn start_transaction_at( - &mut self, - now: Instant, - window: &mut Window, - cx: &mut Context, - ) -> Option { - self.end_selection(window, cx); - if let Some(tx_id) = self - .buffer - .update(cx, |buffer, cx| buffer.start_transaction_at(now, cx)) - { - self.selection_history - .insert_transaction(tx_id, self.selections.disjoint_anchors_arc()); - cx.emit(EditorEvent::TransactionBegun { - transaction_id: tx_id, - }); - Some(tx_id) - } else { - None - } - } - - pub fn end_transaction_at( - &mut self, - now: Instant, - cx: &mut Context, - ) -> Option { - if let Some(transaction_id) = self - .buffer - .update(cx, |buffer, cx| buffer.end_transaction_at(now, cx)) - { - if let Some((_, end_selections)) = - self.selection_history.transaction_mut(transaction_id) - { - *end_selections = Some(self.selections.disjoint_anchors_arc()); - } else { - log::error!("unexpectedly ended a transaction that wasn't started by this editor"); - } - - cx.emit(EditorEvent::Edited { transaction_id }); - Some(transaction_id) - } else { - None - } - } - - pub fn modify_transaction_selection_history( - &mut self, - transaction_id: TransactionId, - modify: impl FnOnce(&mut (Arc<[Selection]>, Option]>>)), - ) -> bool { - self.selection_history - .transaction_mut(transaction_id) - .map(modify) - .is_some() - } - - pub fn set_mark(&mut self, _: &actions::SetMark, window: &mut Window, cx: &mut Context) { - if self.selection_mark_mode { - self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.move_with(&mut |_, sel| { - sel.collapse_to(sel.head(), SelectionGoal::None); - }); - }) - } - self.selection_mark_mode = true; - cx.notify(); - } - - pub fn swap_selection_ends( - &mut self, - _: &actions::SwapSelectionEnds, - window: &mut Window, - cx: &mut Context, - ) { - self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.move_with(&mut |_, sel| { - if sel.start != sel.end { - sel.reversed = !sel.reversed - } - }); - }); - self.request_autoscroll(Autoscroll::newest(), cx); - cx.notify(); - } - - pub fn toggle_focus( - workspace: &mut Workspace, - _: &actions::ToggleFocus, - window: &mut Window, - cx: &mut Context, - ) { - let Some(item) = workspace.recent_active_item_by_type::(cx) else { - return; - }; - workspace.activate_item(&item, true, true, window, cx); - } - - pub fn toggle_fold( - &mut self, - _: &actions::ToggleFold, - window: &mut Window, - cx: &mut Context, - ) { - if self.buffer_kind(cx) == ItemBufferKind::Singleton { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selection = self.selections.newest::(&display_map); - - let range = if selection.is_empty() { - let point = selection.head().to_display_point(&display_map); - let start = DisplayPoint::new(point.row(), 0).to_point(&display_map); - let end = DisplayPoint::new(point.row(), display_map.line_len(point.row())) - .to_point(&display_map); - start..end - } else { - selection.range() - }; - if display_map.folds_in_range(range).next().is_some() { - self.unfold_lines(&Default::default(), window, cx) - } else { - self.fold(&Default::default(), window, cx) - } - } else { - let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); - let buffer_ids: HashSet<_> = self - .selections - .disjoint_anchor_ranges() - .flat_map(|range| multi_buffer_snapshot.buffer_ids_for_range(range)) - .collect(); - - let should_unfold = buffer_ids - .iter() - .any(|buffer_id| self.is_buffer_folded(*buffer_id, cx)); - - for buffer_id in buffer_ids { - if should_unfold { - self.unfold_buffer(buffer_id, cx); - } else { - self.fold_buffer(buffer_id, cx); - } - } - } - } - - pub fn toggle_fold_recursive( - &mut self, - _: &actions::ToggleFoldRecursive, - window: &mut Window, - cx: &mut Context, - ) { - let selection = self.selections.newest::(&self.display_snapshot(cx)); - - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let range = if selection.is_empty() { - let point = selection.head().to_display_point(&display_map); - let start = DisplayPoint::new(point.row(), 0).to_point(&display_map); - let end = DisplayPoint::new(point.row(), display_map.line_len(point.row())) - .to_point(&display_map); - start..end - } else { - selection.range() - }; - if display_map.folds_in_range(range).next().is_some() { - self.unfold_recursive(&Default::default(), window, cx) - } else { - self.fold_recursive(&Default::default(), window, cx) - } - } - - pub fn fold(&mut self, _: &actions::Fold, window: &mut Window, cx: &mut Context) { - if self.buffer_kind(cx) == ItemBufferKind::Singleton { - let mut to_fold = Vec::new(); - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all_adjusted(&display_map); - - for selection in selections { - let range = selection.range().sorted(); - let buffer_start_row = range.start.row; - - if range.start.row != range.end.row { - let mut found = false; - let mut row = range.start.row; - while row <= range.end.row { - if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) - { - found = true; - row = crease.range().end.row + 1; - to_fold.push(crease); - } else { - row += 1 - } - } - if found { - continue; - } - } - - for row in (0..=range.start.row).rev() { - if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) - && crease.range().end.row >= buffer_start_row - { - to_fold.push(crease); - if row <= range.start.row { - break; - } - } - } - } - - self.fold_creases(to_fold, true, window, cx); - } else { - let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); - let buffer_ids = self - .selections - .disjoint_anchor_ranges() - .flat_map(|range| multi_buffer_snapshot.buffer_ids_for_range(range)) - .collect::>(); - for buffer_id in buffer_ids { - self.fold_buffer(buffer_id, cx); - } - } - } - - pub fn toggle_fold_all( - &mut self, - _: &actions::ToggleFoldAll, - window: &mut Window, - cx: &mut Context, - ) { - let has_folds = if self.buffer.read(cx).is_singleton() { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let has_folds = display_map - .folds_in_range(MultiBufferOffset(0)..display_map.buffer_snapshot().len()) - .next() - .is_some(); - has_folds - } else { - let snapshot = self.buffer.read(cx).snapshot(cx); - let has_folds = snapshot - .all_buffer_ids() - .any(|buffer_id| self.is_buffer_folded(buffer_id, cx)); - has_folds - }; - - if has_folds { - self.unfold_all(&actions::UnfoldAll, window, cx); - } else { - self.fold_all(&actions::FoldAll, window, cx); - } - } - - fn fold_at_level( - &mut self, - fold_at: &FoldAtLevel, - window: &mut Window, - cx: &mut Context, - ) { - if !self.buffer.read(cx).is_singleton() { - return; - } - - let fold_at_level = fold_at.0; - let snapshot = self.buffer.read(cx).snapshot(cx); - let mut to_fold = Vec::new(); - let mut stack = vec![(0, snapshot.max_row().0, 1)]; - - let row_ranges_to_keep: Vec> = self - .selections - .all::(&self.display_snapshot(cx)) - .into_iter() - .map(|sel| sel.start.row..sel.end.row) - .collect(); - - while let Some((mut start_row, end_row, current_level)) = stack.pop() { - while start_row < end_row { - match self - .snapshot(window, cx) - .crease_for_buffer_row(MultiBufferRow(start_row)) - { - Some(crease) => { - let nested_start_row = crease.range().start.row + 1; - let nested_end_row = crease.range().end.row; - - if current_level < fold_at_level { - stack.push((nested_start_row, nested_end_row, current_level + 1)); - } else if current_level == fold_at_level { - // Fold iff there is no selection completely contained within the fold region - if !row_ranges_to_keep.iter().any(|selection| { - selection.end >= nested_start_row - && selection.start <= nested_end_row - }) { - to_fold.push(crease); - } - } - - start_row = nested_end_row + 1; - } - None => start_row += 1, - } - } - } - - self.fold_creases(to_fold, true, window, cx); - } - - pub fn fold_at_level_1( - &mut self, - _: &actions::FoldAtLevel1, - window: &mut Window, - cx: &mut Context, - ) { - self.fold_at_level(&actions::FoldAtLevel(1), window, cx); - } - - pub fn fold_at_level_2( - &mut self, - _: &actions::FoldAtLevel2, - window: &mut Window, - cx: &mut Context, - ) { - self.fold_at_level(&actions::FoldAtLevel(2), window, cx); - } - - pub fn fold_at_level_3( - &mut self, - _: &actions::FoldAtLevel3, - window: &mut Window, - cx: &mut Context, - ) { - self.fold_at_level(&actions::FoldAtLevel(3), window, cx); - } - - pub fn fold_at_level_4( - &mut self, - _: &actions::FoldAtLevel4, - window: &mut Window, - cx: &mut Context, - ) { - self.fold_at_level(&actions::FoldAtLevel(4), window, cx); - } - - pub fn fold_at_level_5( - &mut self, - _: &actions::FoldAtLevel5, - window: &mut Window, - cx: &mut Context, - ) { - self.fold_at_level(&actions::FoldAtLevel(5), window, cx); - } - - pub fn fold_at_level_6( - &mut self, - _: &actions::FoldAtLevel6, - window: &mut Window, - cx: &mut Context, - ) { - self.fold_at_level(&actions::FoldAtLevel(6), window, cx); - } - - pub fn fold_at_level_7( - &mut self, - _: &actions::FoldAtLevel7, - window: &mut Window, - cx: &mut Context, - ) { - self.fold_at_level(&actions::FoldAtLevel(7), window, cx); - } - - pub fn fold_at_level_8( - &mut self, - _: &actions::FoldAtLevel8, - window: &mut Window, - cx: &mut Context, - ) { - self.fold_at_level(&actions::FoldAtLevel(8), window, cx); - } - - pub fn fold_at_level_9( - &mut self, - _: &actions::FoldAtLevel9, - window: &mut Window, - cx: &mut Context, - ) { - self.fold_at_level(&actions::FoldAtLevel(9), window, cx); - } - - pub fn fold_all(&mut self, _: &actions::FoldAll, window: &mut Window, cx: &mut Context) { - if self.buffer.read(cx).is_singleton() { - let mut fold_ranges = Vec::new(); - let snapshot = self.buffer.read(cx).snapshot(cx); - - for row in 0..snapshot.max_row().0 { - if let Some(foldable_range) = self - .snapshot(window, cx) - .crease_for_buffer_row(MultiBufferRow(row)) - { - fold_ranges.push(foldable_range); - } - } - - self.fold_creases(fold_ranges, true, window, cx); - } else { - self.toggle_fold_multiple_buffers = cx.spawn_in(window, async move |editor, cx| { - editor - .update_in(cx, |editor, _, cx| { - let snapshot = editor.buffer.read(cx).snapshot(cx); - for buffer_id in snapshot.all_buffer_ids() { - editor.fold_buffer(buffer_id, cx); - } - }) - .ok(); - }); - } - } - - pub fn fold_function_bodies( - &mut self, - _: &actions::FoldFunctionBodies, - window: &mut Window, - cx: &mut Context, - ) { - let snapshot = self.buffer.read(cx).snapshot(cx); - - let ranges = snapshot - .text_object_ranges( - MultiBufferOffset(0)..snapshot.len(), - TreeSitterOptions::default(), - ) - .filter_map(|(range, obj)| (obj == TextObject::InsideFunction).then_some(range)) - .collect::>(); - - let creases = ranges - .into_iter() - .map(|range| Crease::simple(range, self.display_map.read(cx).fold_placeholder.clone())) - .collect(); - - self.fold_creases(creases, true, window, cx); - } - - pub fn fold_recursive( - &mut self, - _: &actions::FoldRecursive, - window: &mut Window, - cx: &mut Context, - ) { - let mut to_fold = Vec::new(); - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all_adjusted(&display_map); - - for selection in selections { - let range = selection.range().sorted(); - let buffer_start_row = range.start.row; - - if range.start.row != range.end.row { - let mut found = false; - for row in range.start.row..=range.end.row { - if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) { - found = true; - to_fold.push(crease); - } - } - if found { - continue; - } - } - - for row in (0..=range.start.row).rev() { - if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) { - if crease.range().end.row >= buffer_start_row { - to_fold.push(crease); - } else { - break; - } - } - } - } - - self.fold_creases(to_fold, true, window, cx); - } - - pub fn fold_at( - &mut self, - buffer_row: MultiBufferRow, - window: &mut Window, - cx: &mut Context, - ) { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - - if let Some(crease) = display_map.crease_for_buffer_row(buffer_row) { - let autoscroll = self - .selections - .all::(&display_map) - .iter() - .any(|selection| crease.range().overlaps(&selection.range())); - - self.fold_creases(vec![crease], autoscroll, window, cx); - } - } - - pub fn unfold_lines(&mut self, _: &UnfoldLines, _window: &mut Window, cx: &mut Context) { - if self.buffer_kind(cx) == ItemBufferKind::Singleton { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let buffer = display_map.buffer_snapshot(); - let selections = self.selections.all::(&display_map); - let ranges = selections - .iter() - .map(|s| { - let range = s.display_range(&display_map).sorted(); - let mut start = range.start.to_point(&display_map); - let mut end = range.end.to_point(&display_map); - start.column = 0; - end.column = buffer.line_len(MultiBufferRow(end.row)); - start..end - }) - .collect::>(); - - self.unfold_ranges(&ranges, true, true, cx); - } else { - let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); - let buffer_ids = self - .selections - .disjoint_anchor_ranges() - .flat_map(|range| multi_buffer_snapshot.buffer_ids_for_range(range)) - .collect::>(); - for buffer_id in buffer_ids { - self.unfold_buffer(buffer_id, cx); - } - } - } - - pub fn unfold_recursive( - &mut self, - _: &UnfoldRecursive, - _window: &mut Window, - cx: &mut Context, - ) { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all::(&display_map); - let ranges = selections - .iter() - .map(|s| { - let mut range = s.display_range(&display_map).sorted(); - *range.start.column_mut() = 0; - *range.end.column_mut() = display_map.line_len(range.end.row()); - let start = range.start.to_point(&display_map); - let end = range.end.to_point(&display_map); - start..end - }) - .collect::>(); - - self.unfold_ranges(&ranges, true, true, cx); - } - - pub fn unfold_at( - &mut self, - buffer_row: MultiBufferRow, - _window: &mut Window, - cx: &mut Context, - ) { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - - let intersection_range = Point::new(buffer_row.0, 0) - ..Point::new( - buffer_row.0, - display_map.buffer_snapshot().line_len(buffer_row), - ); - - let autoscroll = self - .selections - .all::(&display_map) - .iter() - .any(|selection| RangeExt::overlaps(&selection.range(), &intersection_range)); - - self.unfold_ranges(&[intersection_range], true, autoscroll, cx); - } - - pub fn unfold_all( - &mut self, - _: &actions::UnfoldAll, - _window: &mut Window, - cx: &mut Context, - ) { - if self.buffer.read(cx).is_singleton() { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - self.unfold_ranges( - &[MultiBufferOffset(0)..display_map.buffer_snapshot().len()], - true, - true, - cx, - ); - } else { - self.toggle_fold_multiple_buffers = cx.spawn(async move |editor, cx| { - editor - .update(cx, |editor, cx| { - let snapshot = editor.buffer.read(cx).snapshot(cx); - for buffer_id in snapshot.all_buffer_ids() { - editor.unfold_buffer(buffer_id, cx); - } - }) - .ok(); - }); - } - } - - pub fn fold_selected_ranges( - &mut self, - _: &FoldSelectedRanges, - window: &mut Window, - cx: &mut Context, - ) { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all_adjusted(&display_map); - let ranges = selections - .into_iter() - .map(|s| Crease::simple(s.range(), display_map.fold_placeholder.clone())) - .collect::>(); - self.fold_creases(ranges, true, window, cx); - } - - pub fn fold_ranges( - &mut self, - ranges: Vec>, - auto_scroll: bool, - window: &mut Window, - cx: &mut Context, - ) { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let ranges = ranges - .into_iter() - .map(|r| Crease::simple(r, display_map.fold_placeholder.clone())) - .collect::>(); - self.fold_creases(ranges, auto_scroll, window, cx); - } - - pub fn fold_creases( - &mut self, - creases: Vec>, - auto_scroll: bool, - window: &mut Window, - cx: &mut Context, - ) { - if creases.is_empty() { - return; - } - - self.display_map.update(cx, |map, cx| map.fold(creases, cx)); - - if auto_scroll { - self.request_autoscroll(Autoscroll::fit(), cx); - } - - cx.notify(); - - self.scrollbar_marker_state.dirty = true; - self.update_data_on_scroll(false, window, cx); - self.folds_did_change(cx); + }); + Ok(()) + }) } - /// Removes any folds whose ranges intersect any of the given ranges. - pub fn unfold_ranges( + pub fn restart_language_server( &mut self, - ranges: &[Range], - inclusive: bool, - auto_scroll: bool, + _: &RestartLanguageServer, + _: &mut Window, cx: &mut Context, ) { - self.remove_folds_with(ranges, auto_scroll, cx, |map, cx| { - map.unfold_intersecting(ranges.iter().cloned(), inclusive, cx); - }); - self.folds_did_change(cx); - } - - pub fn fold_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { - self.fold_buffers([buffer_id], cx); + if let Some(project) = self.project.clone() { + self.buffer.update(cx, |multi_buffer, cx| { + project.update(cx, |project, cx| { + project.restart_language_servers_for_buffers( + multi_buffer.all_buffers().into_iter().collect(), + HashSet::default(), + cx, + ); + }); + }) + } } - pub fn fold_buffers( + pub fn stop_language_server( &mut self, - buffer_ids: impl IntoIterator, + _: &StopLanguageServer, + _: &mut Window, cx: &mut Context, ) { - if self.buffer().read(cx).is_singleton() { - return; - } - - let ids_to_fold: Vec = buffer_ids - .into_iter() - .filter(|id| !self.is_buffer_folded(*id, cx)) - .collect(); - - if ids_to_fold.is_empty() { - return; + if let Some(project) = self.project.clone() { + self.buffer.update(cx, |multi_buffer, cx| { + project.update(cx, |project, cx| { + project.stop_language_servers_for_buffers( + multi_buffer.all_buffers().into_iter().collect(), + HashSet::default(), + cx, + ); + }); + }); } - - self.display_map.update(cx, |display_map, cx| { - display_map.fold_buffers(ids_to_fold.clone(), cx) - }); - - let snapshot = self.display_snapshot(cx); - self.selections.change_with(&snapshot, |selections| { - for buffer_id in ids_to_fold.iter().copied() { - selections.remove_selections_from_buffer(buffer_id); - } - }); - - cx.emit(EditorEvent::BufferFoldToggled { - ids: ids_to_fold, - folded: true, - }); - cx.notify(); } - pub fn unfold_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { - if self.buffer().read(cx).is_singleton() || !self.is_buffer_folded(buffer_id, cx) { - return; - } - self.display_map.update(cx, |display_map, cx| { - display_map.unfold_buffers([buffer_id], cx); - }); - cx.emit(EditorEvent::BufferFoldToggled { - ids: vec![buffer_id], - folded: false, + fn cancel_language_server_work( + workspace: &mut Workspace, + _: &actions::CancelLanguageServerWork, + _: &mut Window, + cx: &mut Context, + ) { + let project = workspace.project(); + let buffers = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + .map_or(HashSet::default(), |editor| { + editor.read(cx).buffer.read(cx).all_buffers() + }); + project.update(cx, |project, cx| { + project.cancel_language_server_work_for_buffers(buffers, cx); }); - cx.notify(); } - pub fn is_buffer_folded(&self, buffer: BufferId, cx: &App) -> bool { - self.display_map.read(cx).is_buffer_folded(buffer) + fn show_character_palette( + &mut self, + _: &ShowCharacterPalette, + window: &mut Window, + _: &mut Context, + ) { + window.show_character_palette(); } - pub fn has_any_buffer_folded(&self, cx: &App) -> bool { - if self.buffer().read(cx).is_singleton() { - return false; + pub fn toggle_minimap( + &mut self, + _: &ToggleMinimap, + window: &mut Window, + cx: &mut Context, + ) { + if self.supports_minimap(cx) { + self.set_minimap_visibility(self.minimap_visibility.toggle_visibility(), window, cx); } - !self.folded_buffers(cx).is_empty() - } - - pub fn folded_buffers<'a>(&self, cx: &'a App) -> &'a HashSet { - self.display_map.read(cx).folded_buffers() - } - - pub fn disable_header_for_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { - self.display_map.update(cx, |display_map, cx| { - display_map.disable_header_for_buffer(buffer_id, cx); - }); - cx.notify(); } - /// Removes any folds with the given ranges. - pub fn remove_folds_with_type( + pub fn transact( &mut self, - ranges: &[Range], - type_id: TypeId, - auto_scroll: bool, + window: &mut Window, cx: &mut Context, - ) { - self.remove_folds_with(ranges, auto_scroll, cx, |map, cx| { - map.remove_folds_with_type(ranges.iter().cloned(), type_id, cx) - }); - self.folds_did_change(cx); + update: impl FnOnce(&mut Self, &mut Window, &mut Context), + ) -> Option { + self.with_selection_effects_deferred(window, cx, |this, window, cx| { + this.start_transaction_at(Instant::now(), window, cx); + update(this, window, cx); + this.end_transaction_at(Instant::now(), cx) + }) } - fn remove_folds_with( + pub fn start_transaction_at( &mut self, - ranges: &[Range], - auto_scroll: bool, + now: Instant, + window: &mut Window, cx: &mut Context, - update: impl FnOnce(&mut DisplayMap, &mut Context), - ) { - if ranges.is_empty() { - return; + ) -> Option { + self.end_selection(window, cx); + if let Some(tx_id) = self + .buffer + .update(cx, |buffer, cx| buffer.start_transaction_at(now, cx)) + { + self.selection_history + .insert_transaction(tx_id, self.selections.disjoint_anchors_arc()); + cx.emit(EditorEvent::TransactionBegun { + transaction_id: tx_id, + }); + Some(tx_id) + } else { + None } + } - self.display_map.update(cx, update); + pub fn end_transaction_at( + &mut self, + now: Instant, + cx: &mut Context, + ) -> Option { + if let Some(transaction_id) = self + .buffer + .update(cx, |buffer, cx| buffer.end_transaction_at(now, cx)) + { + if let Some((_, end_selections)) = + self.selection_history.transaction_mut(transaction_id) + { + *end_selections = Some(self.selections.disjoint_anchors_arc()); + } else { + log::error!("unexpectedly ended a transaction that wasn't started by this editor"); + } - if auto_scroll { - self.request_autoscroll(Autoscroll::fit(), cx); + cx.emit(EditorEvent::Edited { transaction_id }); + Some(transaction_id) + } else { + None } - - cx.notify(); - self.scrollbar_marker_state.dirty = true; - self.active_indent_guides_state.dirty = true; } - pub fn update_renderer_widths( + pub fn modify_transaction_selection_history( &mut self, - widths: impl IntoIterator, - cx: &mut Context, + transaction_id: TransactionId, + modify: impl FnOnce(&mut (Arc<[Selection]>, Option]>>)), ) -> bool { - self.display_map - .update(cx, |map, cx| map.update_fold_widths(widths, cx)) + self.selection_history + .transaction_mut(transaction_id) + .map(modify) + .is_some() } - pub fn default_fold_placeholder(&self, cx: &App) -> FoldPlaceholder { - self.display_map.read(cx).fold_placeholder.clone() + pub fn toggle_focus( + workspace: &mut Workspace, + _: &actions::ToggleFocus, + window: &mut Window, + cx: &mut Context, + ) { + let Some(item) = workspace.recent_active_item_by_type::(cx) else { + return; + }; + workspace.activate_item(&item, true, true, window, cx); } pub fn set_expand_all_diff_hunks(&mut self, cx: &mut App) { @@ -19581,24 +17809,6 @@ impl Editor { self.focused_block.take() } - pub fn insert_creases( - &mut self, - creases: impl IntoIterator>, - cx: &mut Context, - ) -> Vec { - self.display_map - .update(cx, |map, cx| map.insert_creases(creases, cx)) - } - - pub fn remove_creases( - &mut self, - ids: impl IntoIterator, - cx: &mut Context, - ) -> Vec<(CreaseId, Range)> { - self.display_map - .update(cx, |map, cx| map.remove_creases(ids, cx)) - } - pub fn longest_row(&self, cx: &mut App) -> DisplayRow { self.display_map .update(cx, |map, cx| map.snapshot(cx)) @@ -23650,103 +21860,6 @@ impl Editor { self.read_scroll_position_from_db(item_id, workspace_id, window, cx); } - /// Load folds from the file_folds database table by file path. - /// Used when manually opening a file that was previously closed. - fn load_folds_from_db( - &mut self, - workspace_id: WorkspaceId, - file_path: PathBuf, - window: &mut Window, - cx: &mut Context, - ) { - if self.mode.is_minimap() - || WorkspaceSettings::get(None, cx).restore_on_startup - == RestoreOnStartupBehavior::EmptyTab - { - return; - } - - let Some(folds) = EditorDb::global(cx) - .get_file_folds(workspace_id, &file_path) - .log_err() - else { - return; - }; - if folds.is_empty() { - return; - } - - let snapshot = self.buffer.read(cx).snapshot(cx); - let snapshot_len = snapshot.len().0; - - // Helper: search for fingerprint in buffer, return offset if found - let find_fingerprint = |fingerprint: &str, search_start: usize| -> Option { - let search_start = snapshot - .clip_offset(MultiBufferOffset(search_start), Bias::Left) - .0; - let search_end = snapshot_len.saturating_sub(fingerprint.len()); - - let mut byte_offset = search_start; - for ch in snapshot.chars_at(MultiBufferOffset(search_start)) { - if byte_offset > search_end { - break; - } - if snapshot.contains_str_at(MultiBufferOffset(byte_offset), fingerprint) { - return Some(byte_offset); - } - byte_offset += ch.len_utf8(); - } - None - }; - - let mut search_start = 0usize; - - let valid_folds: Vec<_> = folds - .into_iter() - .filter_map(|(stored_start, stored_end, start_fp, end_fp)| { - let sfp = start_fp?; - let efp = end_fp?; - let efp_len = efp.len(); - - let start_matches = stored_start < snapshot_len - && snapshot.contains_str_at(MultiBufferOffset(stored_start), &sfp); - let efp_check_pos = stored_end.saturating_sub(efp_len); - let end_matches = efp_check_pos >= stored_start - && stored_end <= snapshot_len - && snapshot.contains_str_at(MultiBufferOffset(efp_check_pos), &efp); - - let (new_start, new_end) = if start_matches && end_matches { - (stored_start, stored_end) - } else if sfp == efp { - let new_start = find_fingerprint(&sfp, search_start)?; - let fold_len = stored_end - stored_start; - let new_end = new_start + fold_len; - (new_start, new_end) - } else { - let new_start = find_fingerprint(&sfp, search_start)?; - let efp_pos = find_fingerprint(&efp, new_start + sfp.len())?; - let new_end = efp_pos + efp_len; - (new_start, new_end) - }; - - search_start = new_end; - - if new_end <= new_start { - return None; - } - - Some( - snapshot.clip_offset(MultiBufferOffset(new_start), Bias::Left) - ..snapshot.clip_offset(MultiBufferOffset(new_end), Bias::Right), - ) - }) - .collect(); - - if !valid_folds.is_empty() { - self.fold_ranges(valid_folds, false, window, cx); - } - } - fn lsp_data_enabled(&self) -> bool { self.enable_lsp_data && self.mode().is_full() } @@ -25215,87 +23328,6 @@ impl EditorSnapshot { } } - pub fn render_crease_toggle( - &self, - buffer_row: MultiBufferRow, - row_contains_cursor: bool, - editor: Entity, - window: &mut Window, - cx: &mut App, - ) -> Option { - let folded = self.is_line_folded(buffer_row); - let mut is_foldable = false; - - if let Some(crease) = self - .crease_snapshot - .query_row(buffer_row, self.buffer_snapshot()) - { - is_foldable = true; - match crease { - Crease::Inline { render_toggle, .. } | Crease::Block { render_toggle, .. } => { - if let Some(render_toggle) = render_toggle { - let toggle_callback = - Arc::new(move |folded, window: &mut Window, cx: &mut App| { - if folded { - editor.update(cx, |editor, cx| { - editor.fold_at(buffer_row, window, cx) - }); - } else { - editor.update(cx, |editor, cx| { - editor.unfold_at(buffer_row, window, cx) - }); - } - }); - return Some((render_toggle)( - buffer_row, - folded, - toggle_callback, - window, - cx, - )); - } - } - } - } - - is_foldable |= !self.use_lsp_folding_ranges && self.starts_indent(buffer_row); - - if folded || (is_foldable && (row_contains_cursor || self.gutter_hovered)) { - Some( - Disclosure::new(("gutter_crease", buffer_row.0), !folded) - .toggle_state(folded) - .on_click(window.listener_for(&editor, move |this, _e, window, cx| { - if folded { - this.unfold_at(buffer_row, window, cx); - } else { - this.fold_at(buffer_row, window, cx); - } - })) - .into_any_element(), - ) - } else { - None - } - } - - pub fn render_crease_trailer( - &self, - buffer_row: MultiBufferRow, - window: &mut Window, - cx: &mut App, - ) -> Option { - let folded = self.is_line_folded(buffer_row); - if let Crease::Inline { render_trailer, .. } = self - .crease_snapshot - .query_row(buffer_row, self.buffer_snapshot())? - { - let render_trailer = render_trailer.as_ref()?; - Some(render_trailer(buffer_row, folded, window, cx)) - } else { - None - } - } - pub fn max_line_number_width(&self, style: &EditorStyle, window: &mut Window) -> Pixels { let digit_count = self.widest_line_number().ilog10() + 1; column_pixels(style, digit_count as usize, window) diff --git a/crates/editor/src/fold.rs b/crates/editor/src/fold.rs new file mode 100644 index 00000000000000..1367505b1d0e77 --- /dev/null +++ b/crates/editor/src/fold.rs @@ -0,0 +1,1095 @@ +use super::*; + +impl GutterDimensions { + /// The width of the space reserved for the fold indicators, + /// use alongside 'justify_end' and `gutter_width` to + /// right align content with the line numbers + pub fn fold_area_width(&self) -> Pixels { + self.margin + self.right_padding + } +} + +impl EditorSnapshot { + pub fn render_crease_toggle( + &self, + buffer_row: MultiBufferRow, + row_contains_cursor: bool, + editor: Entity, + window: &mut Window, + cx: &mut App, + ) -> Option { + let folded = self.is_line_folded(buffer_row); + let mut is_foldable = false; + + if let Some(crease) = self + .crease_snapshot + .query_row(buffer_row, self.buffer_snapshot()) + { + is_foldable = true; + match crease { + Crease::Inline { render_toggle, .. } | Crease::Block { render_toggle, .. } => { + if let Some(render_toggle) = render_toggle { + let toggle_callback = + Arc::new(move |folded, window: &mut Window, cx: &mut App| { + if folded { + editor.update(cx, |editor, cx| { + editor.fold_at(buffer_row, window, cx) + }); + } else { + editor.update(cx, |editor, cx| { + editor.unfold_at(buffer_row, window, cx) + }); + } + }); + return Some((render_toggle)( + buffer_row, + folded, + toggle_callback, + window, + cx, + )); + } + } + } + } + + is_foldable |= !self.use_lsp_folding_ranges && self.starts_indent(buffer_row); + + if folded || (is_foldable && (row_contains_cursor || self.gutter_hovered)) { + Some( + Disclosure::new(("gutter_crease", buffer_row.0), !folded) + .toggle_state(folded) + .on_click(window.listener_for(&editor, move |this, _e, window, cx| { + if folded { + this.unfold_at(buffer_row, window, cx); + } else { + this.fold_at(buffer_row, window, cx); + } + })) + .into_any_element(), + ) + } else { + None + } + } + + pub fn render_crease_trailer( + &self, + buffer_row: MultiBufferRow, + window: &mut Window, + cx: &mut App, + ) -> Option { + let folded = self.is_line_folded(buffer_row); + if let Crease::Inline { render_trailer, .. } = self + .crease_snapshot + .query_row(buffer_row, self.buffer_snapshot())? + { + let render_trailer = render_trailer.as_ref()?; + Some(render_trailer(buffer_row, folded, window, cx)) + } else { + None + } + } +} + +impl Editor { + pub fn toggle_fold( + &mut self, + _: &actions::ToggleFold, + window: &mut Window, + cx: &mut Context, + ) { + if self.buffer_kind(cx) == ItemBufferKind::Singleton { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let selection = self.selections.newest::(&display_map); + + let range = if selection.is_empty() { + let point = selection.head().to_display_point(&display_map); + let start = DisplayPoint::new(point.row(), 0).to_point(&display_map); + let end = DisplayPoint::new(point.row(), display_map.line_len(point.row())) + .to_point(&display_map); + start..end + } else { + selection.range() + }; + if display_map.folds_in_range(range).next().is_some() { + self.unfold_lines(&Default::default(), window, cx) + } else { + self.fold(&Default::default(), window, cx) + } + } else { + let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); + let buffer_ids: HashSet<_> = self + .selections + .disjoint_anchor_ranges() + .flat_map(|range| multi_buffer_snapshot.buffer_ids_for_range(range)) + .collect(); + + let should_unfold = buffer_ids + .iter() + .any(|buffer_id| self.is_buffer_folded(*buffer_id, cx)); + + for buffer_id in buffer_ids { + if should_unfold { + self.unfold_buffer(buffer_id, cx); + } else { + self.fold_buffer(buffer_id, cx); + } + } + } + } + + pub fn toggle_fold_recursive( + &mut self, + _: &actions::ToggleFoldRecursive, + window: &mut Window, + cx: &mut Context, + ) { + let selection = self.selections.newest::(&self.display_snapshot(cx)); + + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let range = if selection.is_empty() { + let point = selection.head().to_display_point(&display_map); + let start = DisplayPoint::new(point.row(), 0).to_point(&display_map); + let end = DisplayPoint::new(point.row(), display_map.line_len(point.row())) + .to_point(&display_map); + start..end + } else { + selection.range() + }; + if display_map.folds_in_range(range).next().is_some() { + self.unfold_recursive(&Default::default(), window, cx) + } else { + self.fold_recursive(&Default::default(), window, cx) + } + } + + pub fn fold(&mut self, _: &actions::Fold, window: &mut Window, cx: &mut Context) { + if self.buffer_kind(cx) == ItemBufferKind::Singleton { + let mut to_fold = Vec::new(); + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let selections = self.selections.all_adjusted(&display_map); + + for selection in selections { + let range = selection.range().sorted(); + let buffer_start_row = range.start.row; + + if range.start.row != range.end.row { + let mut found = false; + let mut row = range.start.row; + while row <= range.end.row { + if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) + { + found = true; + row = crease.range().end.row + 1; + to_fold.push(crease); + } else { + row += 1 + } + } + if found { + continue; + } + } + + for row in (0..=range.start.row).rev() { + if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) + && crease.range().end.row >= buffer_start_row + { + to_fold.push(crease); + if row <= range.start.row { + break; + } + } + } + } + + self.fold_creases(to_fold, true, window, cx); + } else { + let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); + let buffer_ids = self + .selections + .disjoint_anchor_ranges() + .flat_map(|range| multi_buffer_snapshot.buffer_ids_for_range(range)) + .collect::>(); + for buffer_id in buffer_ids { + self.fold_buffer(buffer_id, cx); + } + } + } + + pub fn toggle_fold_all( + &mut self, + _: &actions::ToggleFoldAll, + window: &mut Window, + cx: &mut Context, + ) { + let has_folds = if self.buffer.read(cx).is_singleton() { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let has_folds = display_map + .folds_in_range(MultiBufferOffset(0)..display_map.buffer_snapshot().len()) + .next() + .is_some(); + has_folds + } else { + let snapshot = self.buffer.read(cx).snapshot(cx); + let has_folds = snapshot + .all_buffer_ids() + .any(|buffer_id| self.is_buffer_folded(buffer_id, cx)); + has_folds + }; + + if has_folds { + self.unfold_all(&actions::UnfoldAll, window, cx); + } else { + self.fold_all(&actions::FoldAll, window, cx); + } + } + + pub fn fold_at_level_1( + &mut self, + _: &actions::FoldAtLevel1, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(1), window, cx); + } + + pub fn fold_at_level_2( + &mut self, + _: &actions::FoldAtLevel2, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(2), window, cx); + } + + pub fn fold_at_level_3( + &mut self, + _: &actions::FoldAtLevel3, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(3), window, cx); + } + + pub fn fold_at_level_4( + &mut self, + _: &actions::FoldAtLevel4, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(4), window, cx); + } + + pub fn fold_at_level_5( + &mut self, + _: &actions::FoldAtLevel5, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(5), window, cx); + } + + pub fn fold_at_level_6( + &mut self, + _: &actions::FoldAtLevel6, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(6), window, cx); + } + + pub fn fold_at_level_7( + &mut self, + _: &actions::FoldAtLevel7, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(7), window, cx); + } + + pub fn fold_at_level_8( + &mut self, + _: &actions::FoldAtLevel8, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(8), window, cx); + } + + pub fn fold_at_level_9( + &mut self, + _: &actions::FoldAtLevel9, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(9), window, cx); + } + + pub fn fold_all(&mut self, _: &actions::FoldAll, window: &mut Window, cx: &mut Context) { + if self.buffer.read(cx).is_singleton() { + let mut fold_ranges = Vec::new(); + let snapshot = self.buffer.read(cx).snapshot(cx); + + for row in 0..snapshot.max_row().0 { + if let Some(foldable_range) = self + .snapshot(window, cx) + .crease_for_buffer_row(MultiBufferRow(row)) + { + fold_ranges.push(foldable_range); + } + } + + self.fold_creases(fold_ranges, true, window, cx); + } else { + self.toggle_fold_multiple_buffers = cx.spawn_in(window, async move |editor, cx| { + editor + .update_in(cx, |editor, _, cx| { + let snapshot = editor.buffer.read(cx).snapshot(cx); + for buffer_id in snapshot.all_buffer_ids() { + editor.fold_buffer(buffer_id, cx); + } + }) + .ok(); + }); + } + } + + pub fn fold_function_bodies( + &mut self, + _: &actions::FoldFunctionBodies, + window: &mut Window, + cx: &mut Context, + ) { + let snapshot = self.buffer.read(cx).snapshot(cx); + + let ranges = snapshot + .text_object_ranges( + MultiBufferOffset(0)..snapshot.len(), + TreeSitterOptions::default(), + ) + .filter_map(|(range, obj)| (obj == TextObject::InsideFunction).then_some(range)) + .collect::>(); + + let creases = ranges + .into_iter() + .map(|range| Crease::simple(range, self.display_map.read(cx).fold_placeholder.clone())) + .collect(); + + self.fold_creases(creases, true, window, cx); + } + + pub fn fold_recursive( + &mut self, + _: &actions::FoldRecursive, + window: &mut Window, + cx: &mut Context, + ) { + let mut to_fold = Vec::new(); + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let selections = self.selections.all_adjusted(&display_map); + + for selection in selections { + let range = selection.range().sorted(); + let buffer_start_row = range.start.row; + + if range.start.row != range.end.row { + let mut found = false; + for row in range.start.row..=range.end.row { + if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) { + found = true; + to_fold.push(crease); + } + } + if found { + continue; + } + } + + for row in (0..=range.start.row).rev() { + if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) { + if crease.range().end.row >= buffer_start_row { + to_fold.push(crease); + } else { + break; + } + } + } + } + + self.fold_creases(to_fold, true, window, cx); + } + + pub fn fold_at( + &mut self, + buffer_row: MultiBufferRow, + window: &mut Window, + cx: &mut Context, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + + if let Some(crease) = display_map.crease_for_buffer_row(buffer_row) { + let autoscroll = self + .selections + .all::(&display_map) + .iter() + .any(|selection| crease.range().overlaps(&selection.range())); + + self.fold_creases(vec![crease], autoscroll, window, cx); + } + } + + pub fn unfold_lines(&mut self, _: &UnfoldLines, _window: &mut Window, cx: &mut Context) { + if self.buffer_kind(cx) == ItemBufferKind::Singleton { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let buffer = display_map.buffer_snapshot(); + let selections = self.selections.all::(&display_map); + let ranges = selections + .iter() + .map(|s| { + let range = s.display_range(&display_map).sorted(); + let mut start = range.start.to_point(&display_map); + let mut end = range.end.to_point(&display_map); + start.column = 0; + end.column = buffer.line_len(MultiBufferRow(end.row)); + start..end + }) + .collect::>(); + + self.unfold_ranges(&ranges, true, true, cx); + } else { + let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); + let buffer_ids = self + .selections + .disjoint_anchor_ranges() + .flat_map(|range| multi_buffer_snapshot.buffer_ids_for_range(range)) + .collect::>(); + for buffer_id in buffer_ids { + self.unfold_buffer(buffer_id, cx); + } + } + } + + pub fn unfold_recursive( + &mut self, + _: &UnfoldRecursive, + _window: &mut Window, + cx: &mut Context, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let selections = self.selections.all::(&display_map); + let ranges = selections + .iter() + .map(|s| { + let mut range = s.display_range(&display_map).sorted(); + *range.start.column_mut() = 0; + *range.end.column_mut() = display_map.line_len(range.end.row()); + let start = range.start.to_point(&display_map); + let end = range.end.to_point(&display_map); + start..end + }) + .collect::>(); + + self.unfold_ranges(&ranges, true, true, cx); + } + + pub fn unfold_at( + &mut self, + buffer_row: MultiBufferRow, + _window: &mut Window, + cx: &mut Context, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + + let intersection_range = Point::new(buffer_row.0, 0) + ..Point::new( + buffer_row.0, + display_map.buffer_snapshot().line_len(buffer_row), + ); + + let autoscroll = self + .selections + .all::(&display_map) + .iter() + .any(|selection| RangeExt::overlaps(&selection.range(), &intersection_range)); + + self.unfold_ranges(&[intersection_range], true, autoscroll, cx); + } + + pub fn unfold_all( + &mut self, + _: &actions::UnfoldAll, + _window: &mut Window, + cx: &mut Context, + ) { + if self.buffer.read(cx).is_singleton() { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + self.unfold_ranges( + &[MultiBufferOffset(0)..display_map.buffer_snapshot().len()], + true, + true, + cx, + ); + } else { + self.toggle_fold_multiple_buffers = cx.spawn(async move |editor, cx| { + editor + .update(cx, |editor, cx| { + let snapshot = editor.buffer.read(cx).snapshot(cx); + for buffer_id in snapshot.all_buffer_ids() { + editor.unfold_buffer(buffer_id, cx); + } + }) + .ok(); + }); + } + } + + pub fn fold_selected_ranges( + &mut self, + _: &FoldSelectedRanges, + window: &mut Window, + cx: &mut Context, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let selections = self.selections.all_adjusted(&display_map); + let ranges = selections + .into_iter() + .map(|s| Crease::simple(s.range(), display_map.fold_placeholder.clone())) + .collect::>(); + self.fold_creases(ranges, true, window, cx); + } + + pub fn fold_ranges( + &mut self, + ranges: Vec>, + auto_scroll: bool, + window: &mut Window, + cx: &mut Context, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let ranges = ranges + .into_iter() + .map(|r| Crease::simple(r, display_map.fold_placeholder.clone())) + .collect::>(); + self.fold_creases(ranges, auto_scroll, window, cx); + } + + pub fn fold_creases( + &mut self, + creases: Vec>, + auto_scroll: bool, + window: &mut Window, + cx: &mut Context, + ) { + if creases.is_empty() { + return; + } + + self.display_map.update(cx, |map, cx| map.fold(creases, cx)); + + if auto_scroll { + self.request_autoscroll(Autoscroll::fit(), cx); + } + + cx.notify(); + + self.scrollbar_marker_state.dirty = true; + self.update_data_on_scroll(false, window, cx); + self.folds_did_change(cx); + } + + /// Removes any folds whose ranges intersect any of the given ranges. + pub fn unfold_ranges( + &mut self, + ranges: &[Range], + inclusive: bool, + auto_scroll: bool, + cx: &mut Context, + ) { + self.remove_folds_with(ranges, auto_scroll, cx, |map, cx| { + map.unfold_intersecting(ranges.iter().cloned(), inclusive, cx); + }); + self.folds_did_change(cx); + } + + pub fn fold_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { + self.fold_buffers([buffer_id], cx); + } + + pub fn fold_buffers( + &mut self, + buffer_ids: impl IntoIterator, + cx: &mut Context, + ) { + if self.buffer().read(cx).is_singleton() { + return; + } + + let ids_to_fold: Vec = buffer_ids + .into_iter() + .filter(|id| !self.is_buffer_folded(*id, cx)) + .collect(); + + if ids_to_fold.is_empty() { + return; + } + + self.display_map.update(cx, |display_map, cx| { + display_map.fold_buffers(ids_to_fold.clone(), cx) + }); + + let snapshot = self.display_snapshot(cx); + self.selections.change_with(&snapshot, |selections| { + for buffer_id in ids_to_fold.iter().copied() { + selections.remove_selections_from_buffer(buffer_id); + } + }); + + cx.emit(EditorEvent::BufferFoldToggled { + ids: ids_to_fold, + folded: true, + }); + cx.notify(); + } + + pub fn unfold_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { + if self.buffer().read(cx).is_singleton() || !self.is_buffer_folded(buffer_id, cx) { + return; + } + self.display_map.update(cx, |display_map, cx| { + display_map.unfold_buffers([buffer_id], cx); + }); + cx.emit(EditorEvent::BufferFoldToggled { + ids: vec![buffer_id], + folded: false, + }); + cx.notify(); + } + + pub fn is_buffer_folded(&self, buffer: BufferId, cx: &App) -> bool { + self.display_map.read(cx).is_buffer_folded(buffer) + } + + pub fn has_any_buffer_folded(&self, cx: &App) -> bool { + if self.buffer().read(cx).is_singleton() { + return false; + } + !self.folded_buffers(cx).is_empty() + } + + pub fn folded_buffers<'a>(&self, cx: &'a App) -> &'a HashSet { + self.display_map.read(cx).folded_buffers() + } + + pub fn disable_header_for_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { + self.display_map.update(cx, |display_map, cx| { + display_map.disable_header_for_buffer(buffer_id, cx); + }); + cx.notify(); + } + + /// Removes any folds with the given ranges. + pub fn remove_folds_with_type( + &mut self, + ranges: &[Range], + type_id: TypeId, + auto_scroll: bool, + cx: &mut Context, + ) { + self.remove_folds_with(ranges, auto_scroll, cx, |map, cx| { + map.remove_folds_with_type(ranges.iter().cloned(), type_id, cx) + }); + self.folds_did_change(cx); + } + + pub fn update_renderer_widths( + &mut self, + widths: impl IntoIterator, + cx: &mut Context, + ) -> bool { + self.display_map + .update(cx, |map, cx| map.update_fold_widths(widths, cx)) + } + + pub fn default_fold_placeholder(&self, cx: &App) -> FoldPlaceholder { + self.display_map.read(cx).fold_placeholder.clone() + } + + pub fn insert_creases( + &mut self, + creases: impl IntoIterator>, + cx: &mut Context, + ) -> Vec { + self.display_map + .update(cx, |map, cx| map.insert_creases(creases, cx)) + } + + pub fn remove_creases( + &mut self, + ids: impl IntoIterator, + cx: &mut Context, + ) -> Vec<(CreaseId, Range)> { + self.display_map + .update(cx, |map, cx| map.remove_creases(ids, cx)) + } + + pub(super) fn fold_at_level( + &mut self, + fold_at: &FoldAtLevel, + window: &mut Window, + cx: &mut Context, + ) { + if !self.buffer.read(cx).is_singleton() { + return; + } + + let fold_at_level = fold_at.0; + let snapshot = self.buffer.read(cx).snapshot(cx); + let mut to_fold = Vec::new(); + let mut stack = vec![(0, snapshot.max_row().0, 1)]; + + let row_ranges_to_keep: Vec> = self + .selections + .all::(&self.display_snapshot(cx)) + .into_iter() + .map(|sel| sel.start.row..sel.end.row) + .collect(); + + while let Some((mut start_row, end_row, current_level)) = stack.pop() { + while start_row < end_row { + match self + .snapshot(window, cx) + .crease_for_buffer_row(MultiBufferRow(start_row)) + { + Some(crease) => { + let nested_start_row = crease.range().start.row + 1; + let nested_end_row = crease.range().end.row; + + if current_level < fold_at_level { + stack.push((nested_start_row, nested_end_row, current_level + 1)); + } else if current_level == fold_at_level { + // Fold iff there is no selection completely contained within the fold region + if !row_ranges_to_keep.iter().any(|selection| { + selection.end >= nested_start_row + && selection.start <= nested_end_row + }) { + to_fold.push(crease); + } + } + + start_row = nested_end_row + 1; + } + None => start_row += 1, + } + } + } + + self.fold_creases(to_fold, true, window, cx); + } + + pub(super) fn unfold_buffers_with_selections(&mut self, cx: &mut Context) { + if self.buffer().read(cx).is_singleton() { + return; + } + let snapshot = self.buffer.read(cx).snapshot(cx); + let buffer_ids: HashSet = self + .selections + .disjoint_anchor_ranges() + .flat_map(|range| snapshot.buffer_ids_for_range(range)) + .collect(); + for buffer_id in buffer_ids { + self.unfold_buffer(buffer_id, cx); + } + } + + pub(super) fn folds_did_change(&mut self, cx: &mut Context) { + use text::ToOffset as _; + + if self.mode.is_minimap() + || WorkspaceSettings::get(None, cx).restore_on_startup + == RestoreOnStartupBehavior::EmptyTab + { + return; + } + + let display_snapshot = self + .display_map + .update(cx, |display_map, cx| display_map.snapshot(cx)); + let Some(buffer_snapshot) = display_snapshot.buffer_snapshot().as_singleton() else { + return; + }; + let inmemory_folds = display_snapshot + .folds_in_range(MultiBufferOffset(0)..display_snapshot.buffer_snapshot().len()) + .map(|fold| { + let start = fold.range.start.text_anchor_in(buffer_snapshot); + let end = fold.range.end.text_anchor_in(buffer_snapshot); + (start..end).to_point(buffer_snapshot) + }) + .collect(); + self.update_restoration_data(cx, |data| { + data.folds = inmemory_folds; + }); + + let Some(workspace_id) = self.workspace_serialization_id(cx) else { + return; + }; + + // Get file path for path-based fold storage (survives tab close) + let Some(file_path) = self.buffer().read(cx).as_singleton().and_then(|buffer| { + project::File::from_dyn(buffer.read(cx).file()) + .map(|file| Arc::::from(file.abs_path(cx))) + }) else { + return; + }; + + let background_executor = cx.background_executor().clone(); + const FINGERPRINT_LEN: usize = 32; + let db_folds = display_snapshot + .folds_in_range(MultiBufferOffset(0)..display_snapshot.buffer_snapshot().len()) + .map(|fold| { + let start = fold + .range + .start + .text_anchor_in(buffer_snapshot) + .to_offset(buffer_snapshot); + let end = fold + .range + .end + .text_anchor_in(buffer_snapshot) + .to_offset(buffer_snapshot); + + // Extract fingerprints - content at fold boundaries for validation on restore + // Both fingerprints must be INSIDE the fold to avoid capturing surrounding + // content that might change independently. + // start_fp: first min(32, fold_len) bytes of fold content + // end_fp: last min(32, fold_len) bytes of fold content + // Clip to character boundaries to handle multibyte UTF-8 characters. + let fold_len = end - start; + let start_fp_end = buffer_snapshot + .clip_offset(start + std::cmp::min(FINGERPRINT_LEN, fold_len), Bias::Left); + let start_fp: String = buffer_snapshot + .text_for_range(start..start_fp_end) + .collect(); + let end_fp_start = buffer_snapshot + .clip_offset(end.saturating_sub(FINGERPRINT_LEN).max(start), Bias::Right); + let end_fp: String = buffer_snapshot.text_for_range(end_fp_start..end).collect(); + + (start, end, start_fp, end_fp) + }) + .collect::>(); + let db = EditorDb::global(cx); + self.serialize_folds = cx.background_spawn(async move { + background_executor.timer(SERIALIZATION_THROTTLE_TIME).await; + if db_folds.is_empty() { + // No folds - delete any persisted folds for this file + db.delete_file_folds(workspace_id, file_path) + .await + .with_context(|| format!("deleting file folds for workspace {workspace_id:?}")) + .log_err(); + } else { + db.save_file_folds(workspace_id, file_path, db_folds) + .await + .with_context(|| { + format!("persisting file folds for workspace {workspace_id:?}") + }) + .log_err(); + } + }); + } + + pub(super) fn refresh_single_line_folds( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + struct NewlineFold; + let type_id = std::any::TypeId::of::(); + if !self.mode.is_single_line() { + return; + } + let snapshot = self.snapshot(window, cx); + if snapshot.buffer_snapshot().max_point().row == 0 { + return; + } + let task = cx.background_spawn(async move { + let new_newlines = snapshot + .buffer_chars_at(MultiBufferOffset(0)) + .filter_map(|(c, i)| { + if c == '\n' { + Some( + snapshot.buffer_snapshot().anchor_after(i) + ..snapshot.buffer_snapshot().anchor_before(i + 1usize), + ) + } else { + None + } + }) + .collect::>(); + let existing_newlines = snapshot + .folds_in_range(MultiBufferOffset(0)..snapshot.buffer_snapshot().len()) + .filter_map(|fold| { + if fold.placeholder.type_tag == Some(type_id) { + Some(fold.range.start..fold.range.end) + } else { + None + } + }) + .collect::>(); + + (new_newlines, existing_newlines) + }); + self.folding_newlines = cx.spawn(async move |this, cx| { + let (new_newlines, existing_newlines) = task.await; + if new_newlines == existing_newlines { + return; + } + let placeholder = FoldPlaceholder { + render: Arc::new(move |_, _, cx| { + div() + .bg(cx.theme().status().hint_background) + .border_b_1() + .size_full() + .font(ThemeSettings::get_global(cx).buffer_font.clone()) + .border_color(cx.theme().status().hint) + .child("\\n") + .into_any() + }), + constrain_width: false, + merge_adjacent: false, + type_tag: Some(type_id), + collapsed_text: None, + }; + let creases = new_newlines + .into_iter() + .map(|range| Crease::simple(range, placeholder.clone())) + .collect(); + this.update(cx, |this, cx| { + this.display_map.update(cx, |display_map, cx| { + display_map.remove_folds_with_type(existing_newlines, type_id, cx); + display_map.fold(creases, cx); + }); + }) + .ok(); + }); + } + + /// Load folds from the file_folds database table by file path. + /// Used when manually opening a file that was previously closed. + pub(super) fn load_folds_from_db( + &mut self, + workspace_id: WorkspaceId, + file_path: PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + if self.mode.is_minimap() + || WorkspaceSettings::get(None, cx).restore_on_startup + == RestoreOnStartupBehavior::EmptyTab + { + return; + } + + let Some(folds) = EditorDb::global(cx) + .get_file_folds(workspace_id, &file_path) + .log_err() + else { + return; + }; + if folds.is_empty() { + return; + } + + let snapshot = self.buffer.read(cx).snapshot(cx); + let snapshot_len = snapshot.len().0; + + // Helper: search for fingerprint in buffer, return offset if found + let find_fingerprint = |fingerprint: &str, search_start: usize| -> Option { + let search_start = snapshot + .clip_offset(MultiBufferOffset(search_start), Bias::Left) + .0; + let search_end = snapshot_len.saturating_sub(fingerprint.len()); + + let mut byte_offset = search_start; + for ch in snapshot.chars_at(MultiBufferOffset(search_start)) { + if byte_offset > search_end { + break; + } + if snapshot.contains_str_at(MultiBufferOffset(byte_offset), fingerprint) { + return Some(byte_offset); + } + byte_offset += ch.len_utf8(); + } + None + }; + + let mut search_start = 0usize; + + let valid_folds: Vec<_> = folds + .into_iter() + .filter_map(|(stored_start, stored_end, start_fp, end_fp)| { + let sfp = start_fp?; + let efp = end_fp?; + let efp_len = efp.len(); + + let start_matches = stored_start < snapshot_len + && snapshot.contains_str_at(MultiBufferOffset(stored_start), &sfp); + let efp_check_pos = stored_end.saturating_sub(efp_len); + let end_matches = efp_check_pos >= stored_start + && stored_end <= snapshot_len + && snapshot.contains_str_at(MultiBufferOffset(efp_check_pos), &efp); + + let (new_start, new_end) = if start_matches && end_matches { + (stored_start, stored_end) + } else if sfp == efp { + let new_start = find_fingerprint(&sfp, search_start)?; + let fold_len = stored_end - stored_start; + let new_end = new_start + fold_len; + (new_start, new_end) + } else { + let new_start = find_fingerprint(&sfp, search_start)?; + let efp_pos = find_fingerprint(&efp, new_start + sfp.len())?; + let new_end = efp_pos + efp_len; + (new_start, new_end) + }; + + search_start = new_end; + + if new_end <= new_start { + return None; + } + + Some( + snapshot.clip_offset(MultiBufferOffset(new_start), Bias::Left) + ..snapshot.clip_offset(MultiBufferOffset(new_end), Bias::Right), + ) + }) + .collect(); + + if !valid_folds.is_empty() { + self.fold_ranges(valid_folds, false, window, cx); + } + } + + fn remove_folds_with( + &mut self, + ranges: &[Range], + auto_scroll: bool, + cx: &mut Context, + update: impl FnOnce(&mut DisplayMap, &mut Context), + ) { + if ranges.is_empty() { + return; + } + + self.display_map.update(cx, update); + + if auto_scroll { + self.request_autoscroll(Autoscroll::fit(), cx); + } + + cx.notify(); + self.scrollbar_marker_state.dirty = true; + self.active_indent_guides_state.dirty = true; + } +} diff --git a/crates/editor/src/selection.rs b/crates/editor/src/selection.rs new file mode 100644 index 00000000000000..9918383fb48465 --- /dev/null +++ b/crates/editor/src/selection.rs @@ -0,0 +1,899 @@ +use super::*; + +impl Editor { + pub fn sync_selections( + &mut self, + other: Entity, + cx: &mut Context, + ) -> gpui::Subscription { + let other_selections = other.read(cx).selections.disjoint_anchors().to_vec(); + if !other_selections.is_empty() { + self.selections + .change_with(&self.display_snapshot(cx), |selections| { + selections.select_anchors(other_selections); + }); + } + + let other_subscription = cx.subscribe(&other, |this, other, other_evt, cx| { + if let EditorEvent::SelectionsChanged { local: true } = other_evt { + let other_selections = other.read(cx).selections.disjoint_anchors().to_vec(); + if other_selections.is_empty() { + return; + } + let snapshot = this.display_snapshot(cx); + this.selections.change_with(&snapshot, |selections| { + selections.select_anchors(other_selections); + }); + } + }); + + let this_subscription = cx.subscribe_self::(move |this, this_evt, cx| { + if let EditorEvent::SelectionsChanged { local: true } = this_evt { + let these_selections = this.selections.disjoint_anchors().to_vec(); + if these_selections.is_empty() { + return; + } + other.update(cx, |other_editor, cx| { + let snapshot = other_editor.display_snapshot(cx); + other_editor + .selections + .change_with(&snapshot, |selections| { + selections.select_anchors(these_selections); + }) + }); + } + }); + + Subscription::join(other_subscription, this_subscription) + } + + /// Changes selections using the provided mutation function. Changes to `self.selections` occur + /// immediately, but when run within `transact` or `with_selection_effects_deferred` other + /// effects of selection change occur at the end of the transaction. + pub fn change_selections( + &mut self, + effects: SelectionEffects, + window: &mut Window, + cx: &mut Context, + change: impl FnOnce(&mut MutableSelectionsCollection<'_, '_>) -> R, + ) -> R { + let snapshot = self.display_snapshot(cx); + if let Some(state) = &mut self.deferred_selection_effects_state { + state.effects.scroll = effects.scroll.or(state.effects.scroll); + state.effects.completions = effects.completions; + state.effects.nav_history = effects.nav_history.or(state.effects.nav_history); + let (changed, result) = self.selections.change_with(&snapshot, change); + state.changed |= changed; + return result; + } + let mut state = DeferredSelectionEffectsState { + changed: false, + effects, + old_cursor_position: self.selections.newest_anchor().head(), + history_entry: SelectionHistoryEntry { + selections: self.selections.disjoint_anchors_arc(), + select_next_state: self.select_next_state.clone(), + select_prev_state: self.select_prev_state.clone(), + add_selections_state: self.add_selections_state.clone(), + }, + }; + let (changed, result) = self.selections.change_with(&snapshot, change); + state.changed = state.changed || changed; + if self.defer_selection_effects { + self.deferred_selection_effects_state = Some(state); + } else { + self.apply_selection_effects(state, window, cx); + } + result + } + + /// Defers the effects of selection change, so that the effects of multiple calls to + /// `change_selections` are applied at the end. This way these intermediate states aren't added + /// to selection history and the state of popovers based on selection position aren't + /// erroneously updated. + pub fn with_selection_effects_deferred( + &mut self, + window: &mut Window, + cx: &mut Context, + update: impl FnOnce(&mut Self, &mut Window, &mut Context) -> R, + ) -> R { + let already_deferred = self.defer_selection_effects; + self.defer_selection_effects = true; + let result = update(self, window, cx); + if !already_deferred { + self.defer_selection_effects = false; + if let Some(state) = self.deferred_selection_effects_state.take() { + self.apply_selection_effects(state, window, cx); + } + } + result + } + + pub fn has_non_empty_selection(&self, snapshot: &DisplaySnapshot) -> bool { + self.selections + .all_adjusted(snapshot) + .iter() + .any(|selection| !selection.is_empty()) + } + + pub fn is_range_selected(&mut self, range: &Range, cx: &mut Context) -> bool { + if self + .selections + .pending_anchor() + .is_some_and(|pending_selection| { + let snapshot = self.buffer().read(cx).snapshot(cx); + pending_selection.range().includes(range, &snapshot) + }) + { + return true; + } + + self.selections + .disjoint_in_range::(range.clone(), &self.display_snapshot(cx)) + .into_iter() + .any(|selection| { + // This is needed to cover a corner case, if we just check for an existing + // selection in the fold range, having a cursor at the start of the fold + // marks it as selected. Non-empty selections don't cause this. + let length = selection.end - selection.start; + length > 0 + }) + } + + pub fn has_pending_nonempty_selection(&self) -> bool { + let pending_nonempty_selection = match self.selections.pending_anchor() { + Some(Selection { start, end, .. }) => start != end, + None => false, + }; + + pending_nonempty_selection + || (self.columnar_selection_state.is_some() + && self.selections.disjoint_anchors().len() > 1) + } + + pub fn has_pending_selection(&self) -> bool { + self.selections.pending_anchor().is_some() || self.columnar_selection_state.is_some() + } + + pub fn set_selections_from_remote( + &mut self, + selections: Vec>, + pending_selection: Option>, + window: &mut Window, + cx: &mut Context, + ) { + let old_cursor_position = self.selections.newest_anchor().head(); + self.selections + .change_with(&self.display_snapshot(cx), |s| { + s.select_anchors(selections); + if let Some(pending_selection) = pending_selection { + s.set_pending(pending_selection, SelectMode::Character); + } else { + s.clear_pending(); + } + }); + self.selections_did_change( + false, + &old_cursor_position, + SelectionEffects::default(), + window, + cx, + ); + } + + pub fn set_mark(&mut self, _: &actions::SetMark, window: &mut Window, cx: &mut Context) { + if self.selection_mark_mode { + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.move_with(&mut |_, sel| { + sel.collapse_to(sel.head(), SelectionGoal::None); + }); + }) + } + self.selection_mark_mode = true; + cx.notify(); + } + + pub fn swap_selection_ends( + &mut self, + _: &actions::SwapSelectionEnds, + window: &mut Window, + cx: &mut Context, + ) { + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.move_with(&mut |_, sel| { + if sel.start != sel.end { + sel.reversed = !sel.reversed + } + }); + }); + self.request_autoscroll(Autoscroll::newest(), cx); + cx.notify(); + } + + pub(super) fn select( + &mut self, + phase: SelectPhase, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_context_menu(window, cx); + + match phase { + SelectPhase::Begin { + position, + add, + click_count, + } => self.begin_selection(position, add, click_count, window, cx), + SelectPhase::BeginColumnar { + position, + goal_column, + reset, + mode, + } => self.begin_columnar_selection(position, goal_column, reset, mode, window, cx), + SelectPhase::Extend { + position, + click_count, + } => self.extend_selection(position, click_count, window, cx), + SelectPhase::Update { + position, + goal_column, + scroll_delta, + } => self.update_selection(position, goal_column, scroll_delta, window, cx), + SelectPhase::End => self.end_selection(window, cx), + } + } + + pub(super) fn extend_selection( + &mut self, + position: DisplayPoint, + click_count: usize, + window: &mut Window, + cx: &mut Context, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let tail = self + .selections + .newest::(&display_map) + .tail(); + let click_count = click_count.max(match self.selections.select_mode() { + SelectMode::Character => 1, + SelectMode::Word(_) => 2, + SelectMode::Line(_) => 3, + SelectMode::All => 4, + }); + self.begin_selection(position, false, click_count, window, cx); + + let tail_anchor = display_map.buffer_snapshot().anchor_before(tail); + + let current_selection = match self.selections.select_mode() { + SelectMode::Character | SelectMode::All => tail_anchor..tail_anchor, + SelectMode::Word(range) | SelectMode::Line(range) => range.clone(), + }; + + let Some((mut pending_selection, mut pending_mode)) = self.pending_selection_and_mode() + else { + log::error!("extend_selection dispatched with no pending selection"); + return; + }; + + if pending_selection + .start + .cmp(¤t_selection.start, display_map.buffer_snapshot()) + == Ordering::Greater + { + pending_selection.start = current_selection.start; + } + if pending_selection + .end + .cmp(¤t_selection.end, display_map.buffer_snapshot()) + == Ordering::Less + { + pending_selection.end = current_selection.end; + pending_selection.reversed = true; + } + + match &mut pending_mode { + SelectMode::Word(range) | SelectMode::Line(range) => *range = current_selection, + _ => {} + } + + let effects = if EditorSettings::get_global(cx).autoscroll_on_clicks { + SelectionEffects::scroll(Autoscroll::fit()) + } else { + SelectionEffects::no_scroll() + }; + + self.change_selections(effects, window, cx, |s| { + s.set_pending(pending_selection.clone(), pending_mode); + s.set_is_extending(true); + }); + } + + pub(super) fn begin_selection( + &mut self, + position: DisplayPoint, + add: bool, + click_count: usize, + window: &mut Window, + cx: &mut Context, + ) { + if !self.focus_handle.is_focused(window) { + self.last_focused_descendant = None; + window.focus(&self.focus_handle, cx); + } + + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let buffer = display_map.buffer_snapshot(); + let position = display_map.clip_point(position, Bias::Left); + + let start; + let end; + let mode; + let mut auto_scroll; + match click_count { + 1 => { + start = buffer.anchor_before(position.to_point(&display_map)); + end = start; + mode = SelectMode::Character; + auto_scroll = true; + } + 2 => { + let position = display_map + .clip_point(position, Bias::Left) + .to_offset(&display_map, Bias::Left); + let (range, _) = buffer.surrounding_word(position, None); + start = buffer.anchor_before(range.start); + end = buffer.anchor_before(range.end); + mode = SelectMode::Word(start..end); + auto_scroll = true; + } + 3 => { + let position = display_map + .clip_point(position, Bias::Left) + .to_point(&display_map); + let line_start = display_map.prev_line_boundary(position).0; + let next_line_start = buffer.clip_point( + display_map.next_line_boundary(position).0 + Point::new(1, 0), + Bias::Left, + ); + start = buffer.anchor_before(line_start); + end = buffer.anchor_before(next_line_start); + mode = SelectMode::Line(start..end); + auto_scroll = true; + } + _ => { + start = buffer.anchor_before(MultiBufferOffset(0)); + end = buffer.anchor_before(buffer.len()); + mode = SelectMode::All; + auto_scroll = false; + } + } + auto_scroll &= EditorSettings::get_global(cx).autoscroll_on_clicks; + + let point_to_delete: Option = { + let selected_points: Vec> = + self.selections.disjoint_in_range(start..end, &display_map); + + if !add || click_count > 1 { + None + } else if !selected_points.is_empty() { + Some(selected_points[0].id) + } else { + let clicked_point_already_selected = + self.selections.disjoint_anchors().iter().find(|selection| { + selection.start.to_point(buffer) == start.to_point(buffer) + || selection.end.to_point(buffer) == end.to_point(buffer) + }); + + clicked_point_already_selected.map(|selection| selection.id) + } + }; + + let selections_count = self.selections.count(); + let effects = if auto_scroll { + SelectionEffects::default() + } else { + SelectionEffects::no_scroll() + }; + + self.change_selections(effects, window, cx, |s| { + if let Some(point_to_delete) = point_to_delete { + s.delete(point_to_delete); + + if selections_count == 1 { + s.set_pending_anchor_range(start..end, mode); + } + } else { + if !add { + s.clear_disjoint(); + } + + s.set_pending_anchor_range(start..end, mode); + } + }); + } + + pub(super) fn begin_columnar_selection( + &mut self, + position: DisplayPoint, + goal_column: u32, + reset: bool, + mode: ColumnarMode, + window: &mut Window, + cx: &mut Context, + ) { + if !self.focus_handle.is_focused(window) { + self.last_focused_descendant = None; + window.focus(&self.focus_handle, cx); + } + + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + + if reset { + let pointer_position = display_map + .buffer_snapshot() + .anchor_before(position.to_point(&display_map)); + + self.change_selections( + SelectionEffects::scroll(Autoscroll::newest()), + window, + cx, + |s| { + s.clear_disjoint(); + s.set_pending_anchor_range( + pointer_position..pointer_position, + SelectMode::Character, + ); + }, + ); + }; + + let tail = self.selections.newest::(&display_map).tail(); + let selection_anchor = display_map.buffer_snapshot().anchor_before(tail); + self.columnar_selection_state = match mode { + ColumnarMode::FromMouse => Some(ColumnarSelectionState::FromMouse { + selection_tail: selection_anchor, + display_point: if reset { + if position.column() != goal_column { + Some(DisplayPoint::new(position.row(), goal_column)) + } else { + None + } + } else { + None + }, + }), + ColumnarMode::FromSelection => Some(ColumnarSelectionState::FromSelection { + selection_tail: selection_anchor, + }), + }; + + if !reset { + self.select_columns(position, goal_column, &display_map, window, cx); + } + } + + pub(super) fn update_selection( + &mut self, + position: DisplayPoint, + goal_column: u32, + scroll_delta: gpui::Point, + window: &mut Window, + cx: &mut Context, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + + if self.columnar_selection_state.is_some() { + self.select_columns(position, goal_column, &display_map, window, cx); + } else if let Some((mut pending, mode)) = self.pending_selection_and_mode() { + let buffer = display_map.buffer_snapshot(); + let head; + let tail; + match &mode { + SelectMode::Character => { + head = position.to_point(&display_map); + tail = pending.tail().to_point(buffer); + } + SelectMode::Word(original_range) => { + let offset = display_map + .clip_point(position, Bias::Left) + .to_offset(&display_map, Bias::Left); + let original_range = original_range.to_offset(buffer); + + let head_offset = if buffer.is_inside_word(offset, None) + || original_range.contains(&offset) + { + let (word_range, _) = buffer.surrounding_word(offset, None); + if word_range.start < original_range.start { + word_range.start + } else { + word_range.end + } + } else { + offset + }; + + head = head_offset.to_point(buffer); + if head_offset <= original_range.start { + tail = original_range.end.to_point(buffer); + } else { + tail = original_range.start.to_point(buffer); + } + } + SelectMode::Line(original_range) => { + let original_range = original_range.to_point(display_map.buffer_snapshot()); + + let position = display_map + .clip_point(position, Bias::Left) + .to_point(&display_map); + let line_start = display_map.prev_line_boundary(position).0; + let next_line_start = buffer.clip_point( + display_map.next_line_boundary(position).0 + Point::new(1, 0), + Bias::Left, + ); + + if line_start < original_range.start { + head = line_start + } else { + head = next_line_start + } + + if head <= original_range.start { + tail = original_range.end; + } else { + tail = original_range.start; + } + } + SelectMode::All => { + return; + } + }; + + if head < tail { + pending.start = buffer.anchor_before(head); + pending.end = buffer.anchor_before(tail); + pending.reversed = true; + } else { + pending.start = buffer.anchor_before(tail); + pending.end = buffer.anchor_before(head); + pending.reversed = false; + } + + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.set_pending(pending.clone(), mode); + }); + } else { + log::error!("update_selection dispatched with no pending selection"); + return; + } + + self.apply_scroll_delta(scroll_delta, window, cx); + cx.notify(); + } + + pub(super) fn end_selection(&mut self, window: &mut Window, cx: &mut Context) { + self.columnar_selection_state.take(); + if let Some(pending_mode) = self.selections.pending_mode() { + let selections = self + .selections + .all::(&self.display_snapshot(cx)); + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select(selections); + s.clear_pending(); + if s.is_extending() { + s.set_is_extending(false); + } else { + s.set_select_mode(pending_mode); + } + }); + } + } + + fn selections_did_change( + &mut self, + local: bool, + old_cursor_position: &Anchor, + effects: SelectionEffects, + window: &mut Window, + cx: &mut Context, + ) { + self.last_selection_from_search = effects.from_search; + window.invalidate_character_coordinates(); + + // Copy selections to primary selection buffer + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + if local { + let selections = self + .selections + .all::(&self.display_snapshot(cx)); + let buffer_handle = self.buffer.read(cx).read(cx); + + let mut text = String::new(); + for (index, selection) in selections.iter().enumerate() { + let text_for_selection = buffer_handle + .text_for_range(selection.start..selection.end) + .collect::(); + + text.push_str(&text_for_selection); + if index != selections.len() - 1 { + text.push('\n'); + } + } + + if !text.is_empty() { + cx.write_to_primary(ClipboardItem::new_string(text)); + } + } + + let selection_anchors = self.selections.disjoint_anchors_arc(); + + if self.focus_handle.is_focused(window) && self.leader_id.is_none() { + self.buffer.update(cx, |buffer, cx| { + buffer.set_active_selections( + &selection_anchors, + self.selections.line_mode(), + self.cursor_shape, + cx, + ) + }); + } + let display_map = self + .display_map + .update(cx, |display_map, cx| display_map.snapshot(cx)); + let buffer = display_map.buffer_snapshot(); + if self.selections.count() == 1 { + self.add_selections_state = None; + } + self.select_next_state = None; + self.select_prev_state = None; + self.select_syntax_node_history.try_clear(); + self.invalidate_autoclose_regions(&selection_anchors, buffer); + self.snippet_stack.invalidate(&selection_anchors, buffer); + self.take_rename(false, window, cx); + + let newest_selection = self.selections.newest_anchor(); + let new_cursor_position = newest_selection.head(); + let selection_start = newest_selection.start; + + if effects.nav_history.is_none() || effects.nav_history == Some(true) { + self.push_to_nav_history( + *old_cursor_position, + Some(new_cursor_position.to_point(buffer)), + false, + effects.nav_history == Some(true), + cx, + ); + } + + if local { + if let Some((anchor, _)) = buffer.anchor_to_buffer_anchor(new_cursor_position) { + self.register_buffer(anchor.buffer_id, cx); + } + + let mut context_menu = self.context_menu.borrow_mut(); + let completion_menu = match context_menu.as_ref() { + Some(CodeContextMenu::Completions(menu)) => Some(menu), + Some(CodeContextMenu::CodeActions(_)) => { + *context_menu = None; + None + } + None => None, + }; + let completion_position = completion_menu.map(|menu| menu.initial_position); + drop(context_menu); + + if effects.completions + && let Some(completion_position) = completion_position + { + let start_offset = selection_start.to_offset(buffer); + let position_matches = start_offset == completion_position.to_offset(buffer); + let continue_showing = if let Some((snap, ..)) = + buffer.point_to_buffer_offset(completion_position) + && !snap.capability.editable() + { + false + } else if position_matches { + if self.snippet_stack.is_empty() { + buffer.char_kind_before(start_offset, Some(CharScopeContext::Completion)) + == Some(CharKind::Word) + } else { + // Snippet choices can be shown even when the cursor is in whitespace. + // Dismissing the menu with actions like backspace is handled by + // invalidation regions. + true + } + } else { + false + }; + + if continue_showing { + self.open_or_update_completions_menu(None, None, false, window, cx); + } else { + self.hide_context_menu(window, cx); + } + } + + hide_hover(self, cx); + + self.refresh_code_actions_for_selection(window, cx); + self.refresh_document_highlights(cx); + refresh_linked_ranges(self, window, cx); + + self.refresh_selected_text_highlights(&display_map, false, window, cx); + self.refresh_matching_bracket_highlights(&display_map, cx); + self.refresh_outline_symbols_at_cursor(cx); + self.update_visible_edit_prediction(window, cx); + self.hide_blame_popover(true, cx); + if self.git_blame_inline_enabled { + self.start_inline_blame_timer(window, cx); + } + } + + self.blink_manager.update(cx, BlinkManager::pause_blinking); + + if local && !self.suppress_selection_callback { + if let Some(callback) = self.on_local_selections_changed.as_ref() { + let cursor_position = self.selections.newest::(&display_map).head(); + callback(cursor_position, window, cx); + } + } + + cx.emit(EditorEvent::SelectionsChanged { local }); + + let selections = &self.selections.disjoint_anchors_arc(); + if local && let Some(buffer_snapshot) = buffer.as_singleton() { + let inmemory_selections = selections + .iter() + .map(|s| { + let start = s.range().start.text_anchor_in(buffer_snapshot); + let end = s.range().end.text_anchor_in(buffer_snapshot); + (start..end).to_point(buffer_snapshot) + }) + .collect(); + self.update_restoration_data(cx, |data| { + data.selections = inmemory_selections; + }); + + if WorkspaceSettings::get(None, cx).restore_on_startup + != RestoreOnStartupBehavior::EmptyTab + && let Some(workspace_id) = self.workspace_serialization_id(cx) + { + let snapshot = self.buffer().read(cx).snapshot(cx); + let selections = selections.clone(); + let background_executor = cx.background_executor().clone(); + let editor_id = cx.entity().entity_id().as_u64() as ItemId; + let db = EditorDb::global(cx); + self.serialize_selections = cx.background_spawn(async move { + background_executor.timer(SERIALIZATION_THROTTLE_TIME).await; + let db_selections = selections + .iter() + .map(|selection| { + ( + selection.start.to_offset(&snapshot).0, + selection.end.to_offset(&snapshot).0, + ) + }) + .collect(); + + db.save_editor_selections(editor_id, workspace_id, db_selections) + .await + .with_context(|| { + format!( + "persisting editor selections for editor {editor_id}, \ + workspace {workspace_id:?}" + ) + }) + .log_err(); + }); + } + } + + cx.notify(); + } + + fn apply_selection_effects( + &mut self, + state: DeferredSelectionEffectsState, + window: &mut Window, + cx: &mut Context, + ) { + if state.changed { + self.selection_history.push(state.history_entry); + + if let Some(autoscroll) = state.effects.scroll { + self.request_autoscroll(autoscroll, cx); + } + + let old_cursor_position = &state.old_cursor_position; + + self.selections_did_change(true, old_cursor_position, state.effects, window, cx); + + if self.should_open_signature_help_automatically(old_cursor_position, cx) { + self.show_signature_help_auto(window, cx); + } + } + } + + fn select_columns( + &mut self, + head: DisplayPoint, + goal_column: u32, + display_map: &DisplaySnapshot, + window: &mut Window, + cx: &mut Context, + ) { + let Some(columnar_state) = self.columnar_selection_state.as_ref() else { + return; + }; + + let tail = match columnar_state { + ColumnarSelectionState::FromMouse { + selection_tail, + display_point, + } => display_point.unwrap_or_else(|| selection_tail.to_display_point(display_map)), + ColumnarSelectionState::FromSelection { selection_tail } => { + selection_tail.to_display_point(display_map) + } + }; + + let start_row = cmp::min(tail.row(), head.row()); + let end_row = cmp::max(tail.row(), head.row()); + let start_column = cmp::min(tail.column(), goal_column); + let end_column = cmp::max(tail.column(), goal_column); + let reversed = start_column < tail.column(); + + let selection_ranges = (start_row.0..=end_row.0) + .map(DisplayRow) + .filter_map(|row| { + if (matches!(columnar_state, ColumnarSelectionState::FromMouse { .. }) + || start_column <= display_map.line_len(row)) + && !display_map.is_block_line(row) + { + let start = display_map + .clip_point(DisplayPoint::new(row, start_column), Bias::Left) + .to_point(display_map); + let end = display_map + .clip_point(DisplayPoint::new(row, end_column), Bias::Right) + .to_point(display_map); + if reversed { + Some(end..start) + } else { + Some(start..end) + } + } else { + None + } + }) + .collect::>(); + if selection_ranges.is_empty() { + return; + } + + let ranges = match columnar_state { + ColumnarSelectionState::FromMouse { .. } => { + let mut non_empty_ranges = selection_ranges + .iter() + .filter(|selection_range| selection_range.start != selection_range.end) + .peekable(); + if non_empty_ranges.peek().is_some() { + non_empty_ranges.cloned().collect() + } else { + selection_ranges + } + } + _ => selection_ranges, + }; + + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(ranges); + }); + cx.notify(); + } + + fn pending_selection_and_mode(&self) -> Option<(Selection, SelectMode)> { + Some(( + self.selections.pending_anchor()?.clone(), + self.selections.pending_mode()?, + )) + } +} From 10afe2ff281feccc1150753ea91951fe860dc0c8 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Thu, 7 May 2026 13:26:06 -0400 Subject: [PATCH 24/33] git: Make `git::Commit` do an amend when amending is pending (#54472) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - The `git::Commit` action (cmd-enter or ctrl-enter) will now commit a pending amend. --- crates/git_ui/src/commit_modal.rs | 18 ++++++----- crates/git_ui/src/git_panel.rs | 52 ++++++++++++------------------- 2 files changed, 30 insertions(+), 40 deletions(-) diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index 921bcd19608ae0..7532a971ee0d26 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -469,11 +469,7 @@ impl CommitModal { if can_commit { Tooltip::with_meta_in( tooltip, - Some(if is_amend_pending { - &git::Amend - } else { - &git::Commit - }), + Some(&git::Commit), format!( "git commit{}{}", if is_amend_pending { " --amend" } else { "" }, @@ -506,10 +502,16 @@ impl CommitModal { } fn on_commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context) { - if self.git_panel.update(cx, |git_panel, cx| { + let is_amend = self.git_panel.read(cx).amend_pending(); + let did_execute = self.git_panel.update(cx, |git_panel, cx| { git_panel.commit(&self.commit_editor.focus_handle(cx), window, cx) - }) { - telemetry::event!("Git Committed", source = "Git Modal"); + }); + if did_execute { + if is_amend { + telemetry::event!("Git Amended", source = "Git Modal"); + } else { + telemetry::event!("Git Committed", source = "Git Modal"); + } cx.emit(DismissEvent); } } diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 61423e39b78b0b..4262d8bf3982e1 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -31,7 +31,7 @@ use git::repository::{ }; use git::stash::GitStash; use git::status::{DiffStat, StageStatus}; -use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus}; +use git::{Amend, Commit, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus}; use git::{ ExpandCommitEditor, GitHostingProviderRegistry, RestoreTrackedFiles, StageAll, StashAll, StashApply, StashPop, ToggleFillCommitEditor, TrashUntrackedFiles, UnstageAll, @@ -2125,13 +2125,19 @@ impl GitPanel { } } - fn on_commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context) { + fn on_commit(&mut self, _: &Commit, window: &mut Window, cx: &mut Context) { + let is_amend = self.amend_pending; if self.commit(&self.commit_editor.focus_handle(cx), window, cx) { - telemetry::event!("Git Committed", source = "Git Panel"); + if is_amend { + telemetry::event!("Git Amended", source = "Git Panel"); + } else { + telemetry::event!("Git Committed", source = "Git Panel"); + } } } /// Commits staged changes with the current commit message. + /// When `amend_pending` is true, performs an amend commit instead. /// /// Returns `true` if the commit was executed, `false` otherwise. pub(crate) fn commit( @@ -2140,14 +2146,10 @@ impl GitPanel { window: &mut Window, cx: &mut Context, ) -> bool { - if self.amend_pending { - return false; - } - if commit_editor_focus_handle.contains_focused(window, cx) { self.commit_changes( CommitOptions { - amend: false, + amend: self.amend_pending, signoff: self.signoff_enabled, allow_empty: false, }, @@ -2161,17 +2163,16 @@ impl GitPanel { } } - fn on_amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context) { + fn on_amend(&mut self, _: &Amend, window: &mut Window, cx: &mut Context) { if self.amend(&self.commit_editor.focus_handle(cx), window, cx) { telemetry::event!("Git Amended", source = "Git Panel"); } } - /// Amends the most recent commit with staged changes and/or an updated commit message. - /// - /// Uses a two-stage workflow where the first invocation loads the commit - /// message for editing, second invocation performs the amend. Returns - /// `true` if the amend was executed, `false` otherwise. + /// Enters the amend state on first invocation, loading the last commit + /// message for editing. On second invocation, performs the amend commit + /// by delegating to [`Self::commit`]. Returns `true` if a commit was + /// executed. pub(crate) fn amend( &mut self, commit_editor_focus_handle: &FocusHandle, @@ -2181,28 +2182,15 @@ impl GitPanel { if commit_editor_focus_handle.contains_focused(window, cx) { if self.head_commit(cx).is_some() { if !self.amend_pending { - self.set_amend_pending(true, cx); - self.load_last_commit_message(cx); - - return false; + self.toggle_amend_pending(cx); } else { - self.commit_changes( - CommitOptions { - amend: true, - signoff: self.signoff_enabled, - allow_empty: false, - }, - window, - cx, - ); - - return true; + return self.commit(commit_editor_focus_handle, window, cx); } } - return false; + false } else { cx.propagate(); - return false; + false } } pub fn head_commit(&self, cx: &App) -> Option { @@ -4740,7 +4728,7 @@ impl GitPanel { if can_commit { Tooltip::with_meta_in( tooltip, - Some(if amend { &git::Amend } else { &git::Commit }), + Some(&git::Commit), format!( "git commit{}{}", if amend { " --amend" } else { "" }, From 8624bf66893c46e60b909b3c4958dc5e02ee9795 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Thu, 7 May 2026 13:40:28 -0400 Subject: [PATCH 25/33] git: Fix diff hunks not being removed on restore in remote projects (#54823) Closes #48032 When restoring a diff hunk, we first unstage it unconditionally. That unstaging operation is a no-op in terms of the index text if the hunk was already not staged, but previously we would still always do `spawn_set_index_text_job` and bump the `hunk_staging_operation_count_as_of_write`. Bumping that count in turn causes us to skip a diff recalculation in response to the change in the buffer's text. That works out fine in the local case, because when the worktree picks up the write to `.git/index` we kick off another diff recalculation which is not skipped. But in the remote case, we don't get an `UpdateDiffBases` proto message if the index text didn't actually change, so there is no subsequent diff calculation to do the cleanup, and we end up with a stale no-op hunk. This PR fixes the issue by skipping the write to the index and the `hunk_staging_operation_count_as_of_write` bump if the new and old index texts are the same. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - Fixed a bug where restoring diff hunks in remote projects would leave stale no-op hunks in the UI. --- crates/editor/src/folding_ranges.rs | 8 +- crates/editor/src/semantic_tokens.rs | 8 +- crates/project/src/git_store.rs | 6 +- .../remote_server/src/remote_editing_tests.rs | 109 ++++++++++++++++++ 4 files changed, 126 insertions(+), 5 deletions(-) diff --git a/crates/editor/src/folding_ranges.rs b/crates/editor/src/folding_ranges.rs index c59a3e004a8b4f..6c1db5f3ee9086 100644 --- a/crates/editor/src/folding_ranges.rs +++ b/crates/editor/src/folding_ranges.rs @@ -16,7 +16,7 @@ impl Editor { if !self.lsp_data_enabled() || !self.use_document_folding_ranges { return; } - let Some(project) = self.project.clone() else { + let Some(project) = self.project.as_ref().map(|p| p.downgrade()) else { return; }; @@ -43,7 +43,8 @@ impl Editor { let Some(tasks) = editor .update(cx, |_, cx| { - project.read(cx).lsp_store().update(cx, |lsp_store, cx| { + let project = project.upgrade()?; + Some(project.read(cx).lsp_store().update(cx, |lsp_store, cx| { buffers_to_query .into_iter() .map(|buffer| { @@ -52,9 +53,10 @@ impl Editor { async move { (buffer_id, task.await) } }) .collect::>() - }) + })) }) .ok() + .flatten() else { return; }; diff --git a/crates/editor/src/semantic_tokens.rs b/crates/editor/src/semantic_tokens.rs index 29c998ce976fee..23ce7c41be7550 100644 --- a/crates/editor/src/semantic_tokens.rs +++ b/crates/editor/src/semantic_tokens.rs @@ -142,7 +142,10 @@ impl Editor { ); } - let Some((sema, project)) = self.semantics_provider.clone().zip(self.project.clone()) + let Some((sema, project)) = self + .semantics_provider + .clone() + .zip(self.project.as_ref().map(|p| p.downgrade())) else { return; }; @@ -283,6 +286,9 @@ impl Editor { .buffer(buffer_id) .and_then(|buf| buf.read(cx).language().map(|l| l.name())); + let Some(project) = project.upgrade() else { + return; + }; editor.display_map.update(cx, |display_map, cx| { project.read(cx).lsp_store().update(cx, |lsp_store, cx| { let mut token_highlights = Vec::new(); diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 52c16e8a2ba710..9f97f829b0cec4 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -1848,6 +1848,10 @@ impl GitStore { if let BufferDiffEvent::HunksStagedOrUnstaged(new_index_text) = event { let buffer_id = diff.read(cx).buffer_id; if let Some(diff_state) = self.diffs.get(&buffer_id) { + let new_index_text = new_index_text.as_ref().map(|rope| rope.to_string()); + if new_index_text.as_deref() == diff_state.read(cx).index_text.as_deref() { + return; + } let hunk_staging_operation_count = diff_state.update(cx, |diff_state, _| { diff_state.hunk_staging_operation_count += 1; diff_state.hunk_staging_operation_count @@ -1857,7 +1861,7 @@ impl GitStore { log::debug!("hunks changed for {}", path.as_unix_str()); repo.spawn_set_index_text_job( path, - new_index_text.as_ref().map(|rope| rope.to_string()), + new_index_text, Some(hunk_staging_operation_count), cx, ) diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index d31403275cbb14..840485e67506dc 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -2624,6 +2624,115 @@ async fn test_remote_apply_code_action_skips_unadvertised_command( assert_eq!(transaction.0.len(), 0); } +#[gpui::test] +async fn test_remote_restore_unstaged_hunk_clears_diff( + cx: &mut TestAppContext, + server_cx: &mut TestAppContext, +) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme_settings::init(theme::LoadThemes::JustBase, cx); + release_channel::init(semver::Version::new(0, 0, 0), cx); + editor::init(cx); + }); + + use editor::Editor; + use gpui::VisualContext; + + let base_text = " + fn one() -> usize { + 1 + } + " + .unindent(); + let modified_text = " + fn one() -> usize { + 100 + } + " + .unindent(); + + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + path!("/code"), + json!({ + "project1": { + ".git": {}, + "src": { + "lib.rs": modified_text + }, + }, + }), + ) + .await; + fs.set_index_for_repo( + Path::new(path!("/code/project1/.git")), + &[("src/lib.rs", base_text.clone())], + ); + fs.set_head_for_repo( + Path::new(path!("/code/project1/.git")), + &[("src/lib.rs", base_text.clone())], + "deadbeef", + ); + + let (project, _headless) = init_test(&fs, cx, server_cx).await; + let worktree_id = { + let (worktree, _) = project + .update(cx, |project, cx| { + project.find_or_create_worktree(path!("/code/project1"), true, cx) + }) + .await + .unwrap(); + cx.update(|cx| worktree.read(cx).id()) + }; + cx.executor().run_until_parked(); + + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("src/lib.rs")), cx) + }) + .await + .unwrap(); + + let cx = cx.add_empty_window(); + let editor = cx.new_window_entity(|window, cx| { + Editor::for_buffer(buffer, Some(project.clone()), window, cx) + }); + cx.executor().run_until_parked(); + + editor.update_in(cx, |editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let hunks: Vec<_> = editor + .diff_hunks_in_ranges( + &[editor::Anchor::Min..editor::Anchor::Max], + &snapshot.buffer_snapshot(), + ) + .collect(); + assert!(!hunks.is_empty(), "should have diff hunks before restore"); + }); + + cx.update_window_entity(&editor, |editor, window, cx| { + editor.select_all(&editor::actions::SelectAll, window, cx); + editor.git_restore(&git::Restore, window, cx); + }); + cx.executor().run_until_parked(); + + editor.update_in(cx, |editor, _window, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + assert_eq!( + snapshot.text(), + base_text, + "buffer text should match base after restoring all hunks" + ); + + let hunks: Vec<_> = editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot) + .collect(); + assert!(hunks.is_empty(), "should have no diff hunks after restore"); + }); +} + pub async fn init_test( server_fs: &Arc, cx: &mut TestAppContext, From 1b88528b8627b4ddaa48f8a08da8e1321819336e Mon Sep 17 00:00:00 2001 From: Neel Date: Thu, 7 May 2026 19:07:12 +0100 Subject: [PATCH 26/33] agent_ui: Handle Cut for selection mentions (#54694) Following on from https://github.com/zed-industries/zed/pull/54031, implement the same but for `Cut`. Release Notes: - N/A --- crates/agent_ui/src/mention_set.rs | 4 + crates/agent_ui/src/message_editor.rs | 222 ++++++++++++++++++++++---- 2 files changed, 193 insertions(+), 33 deletions(-) diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index fc2cc6523c8d3e..a1d4065ea208a8 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -170,6 +170,10 @@ impl MentionSet { self.mentions.keys().cloned().collect() } + pub fn is_empty(&self) -> bool { + self.mentions.is_empty() + } + pub fn mentions(&self) -> HashSet { self.mentions.values().map(|(uri, _)| uri.clone()).collect() } diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index ec966f2af54899..67be4804d54c4e 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -15,7 +15,7 @@ use anyhow::{Result, anyhow}; use editor::{ Addon, AnchorRangeExt, ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, Inlay, MultiBuffer, MultiBufferOffset, MultiBufferSnapshot, ToOffset, - actions::{Copy, Paste}, + actions::{Copy, Cut, Paste}, code_context_menus::CodeContextMenu, display_map::{CreaseId, CreaseSnapshot}, scroll::Autoscroll, @@ -35,7 +35,7 @@ use project::{ use prompt_store::PromptStore; use rope::Point; use settings::Settings; -use std::{fmt::Write, ops::Range, rc::Rc, sync::Arc}; +use std::{cmp::min, fmt::Write, ops::Range, rc::Rc, sync::Arc}; use theme_settings::ThemeSettings; use ui::{ContextMenu, prelude::*}; use util::paths::PathStyle; @@ -1180,7 +1180,7 @@ impl MessageEditor { } fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { - let Some(text) = self.serialized_copy_text(cx) else { + let Some((text, _)) = self.serialize_selection_with_mentions(false, cx) else { cx.propagate(); return; }; @@ -1189,6 +1189,24 @@ impl MessageEditor { cx.write_to_clipboard(ClipboardItem::new_string(text)); } + fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context) { + let Some((text, ranges)) = self.serialize_selection_with_mentions(true, cx) else { + cx.propagate(); + return; + }; + + cx.stop_propagation(); + self.editor.update(cx, |editor, cx| { + editor.transact(window, cx, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges(ranges); + }); + editor.insert("", window, cx); + }); + }); + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } + fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context) { let editor = self.editor.clone(); window.defer(cx, move |window, cx| { @@ -1689,12 +1707,20 @@ impl MessageEditor { }); } - fn serialized_copy_text(&self, cx: &mut App) -> Option { + fn serialize_selection_with_mentions( + &self, + expand_empty_to_line: bool, + cx: &mut App, + ) -> Option<(String, Vec>)> { + if self.mention_set.read(cx).is_empty() { + return None; + } + let display_snapshot = self .editor .update(cx, |editor, cx| editor.display_snapshot(cx)); let editor = self.editor.read(cx); - if !editor.has_non_empty_selection(&display_snapshot) { + if !expand_empty_to_line && !editor.has_non_empty_selection(&display_snapshot) { return None; } @@ -1715,48 +1741,55 @@ impl MessageEditor { }) .collect::>(); + let line_mode = editor.selections.line_mode(); + let max_point = snapshot.max_point(); + let point_selections = editor.selections.all::(&display_snapshot); + let mut text = String::new(); + let mut ranges = Vec::with_capacity(point_selections.len()); let mut has_mentions = false; let mut is_first = true; + let mut prev_was_entire_line = false; + + for mut selection in point_selections { + let is_entire_line = (selection.is_empty() && expand_empty_to_line) || line_mode; + if is_entire_line { + selection.start = Point::new(selection.start.row, 0); + if !selection.is_empty() && selection.end.column == 0 { + selection.end = min(max_point, selection.end); + } else { + selection.end = min(max_point, Point::new(selection.end.row + 1, 0)); + } + } + let range = selection.start.to_offset(&snapshot)..selection.end.to_offset(&snapshot); - for selection in editor - .selections - .all::(&display_snapshot) - { if is_first { is_first = false; - } else { + } else if !prev_was_entire_line { text.push('\n'); } + prev_was_entire_line = is_entire_line; - let mut overlapping_mentions = mention_ranges + let mut cursor = range.start; + for (start, end, uri) in mention_ranges .iter() - .filter(|(start, end, _)| *start < selection.end && selection.start < *end) - .peekable(); - - if overlapping_mentions.peek().is_none() { - text.extend(snapshot.text_for_range(selection.start..selection.end)); - continue; - } - - has_mentions = true; - - let mut cursor = selection.start; - for (start, end, uri) in overlapping_mentions { + .filter(|(start, end, _)| *start < range.end && range.start < *end) + { if cursor < *start { text.extend(snapshot.text_for_range(cursor..*start)); } - write!(text, "{}", uri.as_link()).unwrap(); cursor = *end; + has_mentions = true; } - - if cursor < selection.end { - text.extend(snapshot.text_for_range(cursor..selection.end)); + if cursor < range.end { + text.extend(snapshot.text_for_range(cursor..range.end)); } + + ranges.push(range); } - has_mentions.then_some(text) + has_mentions.then_some((text, ranges)) } } @@ -1775,6 +1808,7 @@ impl Render for MessageEditor { .on_action(cx.listener(Self::chat_with_follow)) .on_action(cx.listener(Self::cancel)) .capture_action(cx.listener(Self::copy)) + .capture_action(cx.listener(Self::cut)) .on_action(cx.listener(Self::paste_raw)) .capture_action(cx.listener(Self::paste)) .flex_1() @@ -1991,7 +2025,7 @@ mod tests { use base64::Engine as _; use editor::{ AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset, SelectionEffects, - actions::Paste, + actions::{Cut, Paste}, }; use fs::FakeFs; @@ -4029,7 +4063,8 @@ mod tests { let copied_text = source_message_editor.update(&mut cx, |message_editor, cx| { message_editor - .serialized_copy_text(cx) + .serialize_selection_with_mentions(false, cx) + .map(|(text, _)| text) .expect("selection mentions should serialize") }); let expected_text = format!( @@ -4094,7 +4129,9 @@ mod tests { message_editor: Entity, first_uri: MentionUri, first_range: Range, + second_uri: MentionUri, second_range: Range, + buffer_len: MultiBufferOffset, } async fn setup_selection_mention_fixture( @@ -4119,7 +4156,7 @@ mod tests { line_range: 2..=3, }; - message_editor.update_in(&mut cx, |message_editor, window, cx| { + let buffer_len = message_editor.update_in(&mut cx, |message_editor, window, cx| { message_editor.set_text(source_text, window, cx); let snapshot = message_editor @@ -4174,6 +4211,8 @@ mod tests { ); }); } + + snapshot.len() }); ( @@ -4181,7 +4220,9 @@ mod tests { message_editor, first_uri, first_range, + second_uri, second_range, + buffer_len, }, cx, ) @@ -4209,7 +4250,9 @@ mod tests { let copied = fixture .message_editor .update(&mut cx, |message_editor, cx| { - message_editor.serialized_copy_text(cx) + message_editor + .serialize_selection_with_mentions(false, cx) + .map(|(text, _)| text) }); assert_eq!(copied, Some(fixture.first_uri.as_link().to_string())); @@ -4241,7 +4284,9 @@ mod tests { let copied = fixture .message_editor .update(&mut cx, |message_editor, cx| { - message_editor.serialized_copy_text(cx) + message_editor + .serialize_selection_with_mentions(false, cx) + .map(|(text, _)| text) }); assert_eq!(copied, None); @@ -4297,6 +4342,117 @@ mod tests { } } + #[gpui::test] + async fn test_cut_with_selection_mentions_serializes_and_removes(cx: &mut TestAppContext) { + init_test(cx); + + let (fixture, mut cx) = setup_selection_mention_fixture(cx).await; + + let buffer_len = fixture.buffer_len; + fixture + .message_editor + .update_in(&mut cx, |message_editor, window, cx| { + message_editor.editor.update(cx, |editor, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([MultiBufferOffset(0)..buffer_len]); + }); + }); + message_editor.cut(&Cut, window, cx); + }); + + let expected_text = format!( + "{} needs work\n{} looks fine", + fixture.first_uri.as_link(), + fixture.second_uri.as_link() + ); + + let clipboard_text = cx + .read_from_clipboard() + .and_then(|item| match item.entries().first().cloned() { + Some(ClipboardEntry::String(entry)) => Some(entry.text().to_string()), + _ => None, + }) + .expect("cut should write serialized text to clipboard"); + assert_eq!(clipboard_text, expected_text); + + let remaining_text = fixture.message_editor.read_with(&cx, |message_editor, cx| { + message_editor.editor.read(cx).text(cx) + }); + assert_eq!(remaining_text, ""); + } + + #[gpui::test] + async fn test_cut_with_empty_cursor_on_mention_line_removes_whole_line( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let (fixture, mut cx) = setup_selection_mention_fixture(cx).await; + + let cursor_offset = MultiBufferOffset(fixture.first_range.end + 4); + fixture + .message_editor + .update_in(&mut cx, |message_editor, window, cx| { + message_editor.editor.update(cx, |editor, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([cursor_offset..cursor_offset]); + }); + }); + message_editor.cut(&Cut, window, cx); + }); + + let clipboard_text = cx + .read_from_clipboard() + .and_then(|item| match item.entries().first().cloned() { + Some(ClipboardEntry::String(entry)) => Some(entry.text().to_string()), + _ => None, + }) + .expect("cut should write serialized text to clipboard"); + assert_eq!( + clipboard_text, + format!("{} needs work\n", fixture.first_uri.as_link()) + ); + + let remaining_text = fixture.message_editor.read_with(&cx, |message_editor, cx| { + message_editor.editor.read(cx).text(cx) + }); + assert_eq!(remaining_text, "selection looks fine"); + } + + #[gpui::test] + async fn test_serialized_cut_text_returns_none_when_mentions_outside_selection( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let (fixture, mut cx) = setup_selection_mention_fixture(cx).await; + + let between_start = fixture.first_range.end; + let between_end = fixture.second_range.start - 1; + fixture + .message_editor + .update_in(&mut cx, |message_editor, window, cx| { + message_editor.editor.update(cx, |editor, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([ + MultiBufferOffset(between_start)..MultiBufferOffset(between_end) + ]); + }); + }); + }); + + let result = fixture + .message_editor + .update(&mut cx, |message_editor, cx| { + message_editor.serialize_selection_with_mentions(true, cx) + }); + + assert!( + result.is_none(), + "serialize_selection_with_mentions should return None so the default editor cut runs" + ); + } + #[gpui::test] async fn test_paste_mention_link_with_completion_trigger_does_not_panic( cx: &mut TestAppContext, From 8bdcce86b69252bf9a1e65a52c2a8268af7e0106 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 7 May 2026 17:43:07 -0300 Subject: [PATCH 27/33] settings_ui: Stop reading the clipboard on every frame (#56075) `render_settings_item_link` was calling `cx.read_from_clipboard()` during render so it could show a check icon next to the copy-link button when the matching link was on the clipboard. This had two problems: - A clipboard read per visible setting per frame is too expensive. - On Windows, reading the clipboard pumps the system message queue. If a queued message handler updates `App` while we're still rendering, GPUI panics with `RefCell already borrowed` (many occurrences observed). Track the `json_path` of the most recently copied setting locally instead. The check icon now reflects what was copied in this session via this UI rather than whatever is on the system clipboard. While this removes the most common offender, the underlying `gpui_windows` reentrancy bug still exists: `on_close` / `on_request_frame` callbacks can be invoked while `App` is already borrowed on Windows, and can be triggered by any other clipboard-touching code path. We should consider a follow-up PR that handles this at the platform layer -- either by deferring callbacks that re-borrow `App`, or by guarding individual handlers in `gpui_windows::events` against reentrant `borrow_mut` calls. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed a crash on Windows that could occur when closing the settings window - Improved the overall performance of the settings window --- crates/settings_ui/src/settings_ui.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index f17caafce5ea07..7b88c2affe99ae 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -763,6 +763,7 @@ pub struct SettingsWindow { list_state: ListState, shown_errors: HashSet, pub(crate) regex_validation_error: Option, + last_copied_link_path: Option<&'static str>, } struct SearchDocument { @@ -1035,6 +1036,7 @@ impl SettingsPageItem { sub_page_link.title.clone(), sub_page_link.json_path, false, + settings_window, cx, )), ) @@ -1228,6 +1230,7 @@ fn render_settings_item( setting_item.description, setting_item.field.json_path(), sub_field, + settings_window, cx, )) }) @@ -1237,16 +1240,13 @@ fn render_settings_item_link( id: impl Into, json_path: Option<&'static str>, sub_field: bool, + settings_window: &SettingsWindow, cx: &mut Context<'_, SettingsWindow>, ) -> impl IntoElement { - let clipboard_has_link = cx - .read_from_clipboard() - .and_then(|entry| entry.text()) - .map_or(false, |maybe_url| { - json_path.is_some() && maybe_url.strip_prefix("zed://settings/") == json_path - }); + let copied_link_matches = + json_path.is_some() && json_path == settings_window.last_copied_link_path; - let (link_icon, link_icon_color) = if clipboard_has_link { + let (link_icon, link_icon_color) = if copied_link_matches { (IconName::Check, Color::Success) } else { (IconName::Link, Color::Muted) @@ -1271,9 +1271,10 @@ fn render_settings_item_link( .shape(IconButtonShape::Square) .tooltip(Tooltip::text("Copy Link")) .when_some(json_path, |this, path| { - this.on_click(cx.listener(move |_, _, _, cx| { + this.on_click(cx.listener(move |this, _, _, cx| { let link = format!("zed://settings/{}", path); cx.write_to_clipboard(ClipboardItem::new_string(link)); + this.last_copied_link_path = Some(path); cx.notify(); })) }), @@ -1685,6 +1686,7 @@ impl SettingsWindow { shown_errors: HashSet::default(), regex_validation_error: None, list_state, + last_copied_link_path: None, }; this.fetch_files(window, cx); @@ -4472,6 +4474,7 @@ pub mod test { list_state: ListState::new(0, gpui::ListAlignment::Top, px(0.0)), shown_errors: HashSet::default(), regex_validation_error: None, + last_copied_link_path: None, } } } @@ -4597,6 +4600,7 @@ pub mod test { list_state: ListState::new(0, gpui::ListAlignment::Top, px(0.0)), shown_errors: HashSet::default(), regex_validation_error: None, + last_copied_link_path: None, }; settings_window.build_filter_table(); From ebc46d7e0665f701e7b6b1332e17c2d9f9d2bb8d Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 7 May 2026 23:48:12 +0200 Subject: [PATCH 28/33] Update rmcp and rpassword (#56096) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- Cargo.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f505b58b5f7d82..f4626a83d5c6b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14934,9 +14934,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.3.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2231b2c085b371c01bc90c0e6c1cab8834711b6394533375bdbf870b0166d419" +checksum = "e12ca9067b5ebfbd5b3fcdc4acfceb81aa7d5ab2a879dff7cb75d22434276aad" dependencies = [ "async-trait", "base64 0.22.1", @@ -14956,9 +14956,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "1.3.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36ea0e100fadf81be85d7ff70f86cd805c7572601d4ab2946207f36540854b43" +checksum = "7caa6743cc0888e433105fe1bc551a7f607940b126a37bc97b478e86064627eb" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -15031,13 +15031,13 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "rpassword" -version = "7.4.0" +version = "7.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" +checksum = "5ac5b223d9738ef56e0b98305410be40fa0941bf6036c56f1506751e43552d64" dependencies = [ "libc", "rtoolbox", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] From dccea211edfed189db0704ef1247e446aca81150 Mon Sep 17 00:00:00 2001 From: Karol Broda <122811026+karol-broda@users.noreply.github.com> Date: Fri, 8 May 2026 00:55:36 +0200 Subject: [PATCH 29/33] auto_update: Add NixOS rsync install hint (#56097) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable NixOS users who are missing rsync get a generic "Please install rsync using your package manager" message. Release Notes: - Improved auto update error message for NixOS users missing rsync --- crates/auto_update/src/auto_update.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index c1b15aa3b6c371..c14de5a801c442 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -81,6 +81,11 @@ fn linux_rsync_install_hint() -> &'static str { || distribution_id == "almalinux" }) { Some("Install it with: sudo dnf install rsync") + } else if distribution_ids + .iter() + .any(|distribution_id| distribution_id == "nixos") + { + Some("Install pkgs.rsync from nixpkgs") } else { None }; From 6766514599c6f8ce6530ccc685db5e0d68c44f32 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Fri, 8 May 2026 01:45:12 -0400 Subject: [PATCH 30/33] Improve auto watch (#56126) This PR fixes a few bugs, updates some UI, and improves testing of auto watch. It'll likely be easier to review commit by commit: - Swapped the Copy Channel Link and Auto Watch buttons so Auto Watch appears in a better position. The UI is still not great, but I think this tweak will improve it until someone on design can help. Before: 589131021-c967dfe1-9026-4a1d-a399-b735303f2de0 After: 589131282-607e15a5-e50c-4a8e-b22c-327f2e7b8ab5 - Disable Auto Watch when following another collaborator, with test coverage for that behavior. We currently disable following when engaging auto watch, and now we disable auto watch when following. They are mutually exclusive and I think the feels correct. - Refactored Auto Watch integration tests to use channels API instead of room API. - Improved test robustness by using assertions to identify `SharedScreen` items by type and `peer_id` instead of tab title text. - Fixed Auto Watch for returning channel participants by emitting `RemoteVideoTracksChanged` when removing a participant with active video tracks, with regression coverage for leave/rejoin/share. Self-Review Checklist: - [X] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [X] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [X] Tests cover the new/changed behavior - [X] Performance impact has been considered and is acceptable Closes Release Notes: - N/A --- crates/call/src/call_impl/room.rs | 5 + .../tests/integration/auto_watch_tests.rs | 312 +++++++++++++++--- crates/collab_ui/src/collab_panel.rs | 14 +- crates/workspace/src/workspace.rs | 1 + 4 files changed, 275 insertions(+), 57 deletions(-) diff --git a/crates/call/src/call_impl/room.rs b/crates/call/src/call_impl/room.rs index 658c2b620643f5..21b40822cf0518 100644 --- a/crates/call/src/call_impl/room.rs +++ b/crates/call/src/call_impl/room.rs @@ -935,6 +935,11 @@ impl Room { for sid in participant.video_tracks.keys() { cx.emit(Event::RemoteVideoTrackUnsubscribed { sid: sid.clone() }); } + if !participant.video_tracks.is_empty() { + cx.emit(Event::RemoteVideoTracksChanged { + participant_id: participant.peer_id, + }); + } false } }); diff --git a/crates/collab/tests/integration/auto_watch_tests.rs b/crates/collab/tests/integration/auto_watch_tests.rs index c8d395407b362b..f119e1a4af4d94 100644 --- a/crates/collab/tests/integration/auto_watch_tests.rs +++ b/crates/collab/tests/integration/auto_watch_tests.rs @@ -1,18 +1,20 @@ use crate::TestServer; use call::ActiveCall; +use client::ChannelId; use gpui::{App, BackgroundExecutor, Entity, TestAppContext, TestScreenCaptureSource}; use project::Project; -use serde_json::json; -use util::path; -use workspace::Workspace; +use rpc::proto::PeerId; +use workspace::{AutoWatch, SharedScreen, Workspace}; use super::TestClient; struct AutoWatchTestSetup { client_a: TestClient, - _client_b: TestClient, - _client_c: TestClient, - project_a: Entity, + client_b: TestClient, + client_c: TestClient, + channel_id: ChannelId, + user_a_project: Entity, + user_b_project: Entity, } async fn setup_auto_watch_test( @@ -20,35 +22,67 @@ async fn setup_auto_watch_test( user_a: &mut TestAppContext, user_b: &mut TestAppContext, user_c: &mut TestAppContext, +) -> AutoWatchTestSetup { + setup_auto_watch_test_with_initial_participants(server, user_a, user_b, user_c, true).await +} + +async fn setup_auto_watch_late_joiner_test( + server: &mut TestServer, + user_a: &mut TestAppContext, + user_b: &mut TestAppContext, + user_c: &mut TestAppContext, +) -> AutoWatchTestSetup { + setup_auto_watch_test_with_initial_participants(server, user_a, user_b, user_c, false).await +} + +async fn setup_auto_watch_test_with_initial_participants( + server: &mut TestServer, + user_a: &mut TestAppContext, + user_b: &mut TestAppContext, + user_c: &mut TestAppContext, + join_user_c: bool, ) -> AutoWatchTestSetup { let client_a = server.create_client(user_a, "user_a").await; let client_b = server.create_client(user_b, "user_b").await; let client_c = server.create_client(user_c, "user_c").await; - server - .create_room(&mut [ + let channel_id = server + .make_channel( + "the-channel", + None, (&client_a, user_a), - (&client_b, user_b), - (&client_c, user_c), - ]) + &mut [(&client_b, user_b), (&client_c, user_c)], + ) .await; - let active_call_a = user_a.read(ActiveCall::global); + let user_a_project = client_a.build_empty_local_project(false, user_a); + let user_b_project = client_b.build_empty_local_project(false, user_b); - client_a - .fs() - .insert_tree(path!("/a"), json!({ "file.txt": "content" })) - .await; - let (project_a, _worktree_id) = client_a.build_local_project(path!("/a"), user_a).await; + let active_call_a = user_a.read(ActiveCall::global); active_call_a - .update(user_a, |call, cx| call.set_location(Some(&project_a), cx)) + .update(user_a, |call, cx| call.join_channel(channel_id, cx)) .await .unwrap(); + let active_call_b = user_b.read(ActiveCall::global); + active_call_b + .update(user_b, |call, cx| call.join_channel(channel_id, cx)) + .await + .unwrap(); + + if join_user_c { + let active_call_c = user_c.read(ActiveCall::global); + active_call_c + .update(user_c, |call, cx| call.join_channel(channel_id, cx)) + .await + .unwrap(); + } AutoWatchTestSetup { client_a, - _client_b: client_b, - _client_c: client_c, - project_a, + client_b, + client_c, + channel_id, + user_a_project, + user_b_project, } } @@ -61,7 +95,9 @@ async fn test_auto_watch_opens_existing_share_on_toggle( ) { let mut server = TestServer::start(executor.clone()).await; let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await; - let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a); + let (workspace_a, user_a) = setup + .client_a + .build_workspace(&setup.user_a_project, user_a); executor.run_until_parked(); start_screen_share(user_b).await; @@ -73,7 +109,11 @@ async fn test_auto_watch_opens_existing_share_on_toggle( executor.run_until_parked(); workspace_a.update(user_a, |workspace, cx| { - assert_active_matches_title(workspace, "user_b's screen", cx); + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_b.peer_id().unwrap(), + cx, + ); }); } @@ -86,7 +126,9 @@ async fn test_auto_watch_opens_share_when_no_one_is_sharing_yet( ) { let mut server = TestServer::start(executor.clone()).await; let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await; - let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a); + let (workspace_a, user_a) = setup + .client_a + .build_workspace(&setup.user_a_project, user_a); workspace_a.update_in(user_a, |workspace, window, cx| { workspace.toggle_auto_watch(window, cx); @@ -96,7 +138,11 @@ async fn test_auto_watch_opens_share_when_no_one_is_sharing_yet( executor.run_until_parked(); workspace_a.update(user_a, |workspace, cx| { - assert_active_matches_title(workspace, "user_b's screen", cx); + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_b.peer_id().unwrap(), + cx, + ); }); } @@ -109,7 +155,9 @@ async fn test_auto_watch_switches_to_next_share_on_share_end( ) { let mut server = TestServer::start(executor.clone()).await; let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await; - let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a); + let (workspace_a, user_a) = setup + .client_a + .build_workspace(&setup.user_a_project, user_a); workspace_a.update_in(user_a, |workspace, window, cx| { workspace.toggle_auto_watch(window, cx); @@ -119,7 +167,11 @@ async fn test_auto_watch_switches_to_next_share_on_share_end( executor.run_until_parked(); workspace_a.update(user_a, |workspace, cx| { - assert_active_matches_title(workspace, "user_b's screen", cx); + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_b.peer_id().unwrap(), + cx, + ); }); start_screen_share(user_c).await; @@ -129,7 +181,11 @@ async fn test_auto_watch_switches_to_next_share_on_share_end( executor.run_until_parked(); workspace_a.update(user_a, |workspace, cx| { - assert_active_matches_title(workspace, "user_c's screen", cx); + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_c.peer_id().unwrap(), + cx, + ); }); } @@ -142,7 +198,9 @@ async fn test_auto_watch_ignores_shares_while_user_is_sharing( ) { let mut server = TestServer::start(executor.clone()).await; let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await; - let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a); + let (workspace_a, user_a) = setup + .client_a + .build_workspace(&setup.user_a_project, user_a); start_screen_share(user_a).await; executor.run_until_parked(); @@ -155,16 +213,11 @@ async fn test_auto_watch_ignores_shares_while_user_is_sharing( }); executor.run_until_parked(); - // Ensure that no screen share is found in user a's tab bar workspace_a.update(user_a, |workspace, cx| { - let has_shared_screen_tab = workspace - .active_pane() - .read(cx) - .items() - .any(|item| item.tab_content_text(0, cx).contains("screen")); - assert!( - !has_shared_screen_tab, - "should not open anyone's screen share when toggling on while sharing" + assert_no_screen_share_tabs_exist( + workspace, + "should not open anyone's screen share when toggling on while sharing", + cx, ); }); } @@ -178,7 +231,9 @@ async fn test_auto_watch_opens_share_after_local_user_stops_sharing( ) { let mut server = TestServer::start(executor.clone()).await; let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await; - let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a); + let (workspace_a, user_a) = setup + .client_a + .build_workspace(&setup.user_a_project, user_a); workspace_a.update_in(user_a, |workspace, window, cx| { workspace.toggle_auto_watch(window, cx); @@ -193,7 +248,11 @@ async fn test_auto_watch_opens_share_after_local_user_stops_sharing( executor.run_until_parked(); workspace_a.update(user_a, |workspace, cx| { - assert_active_matches_title(workspace, "user_b's screen", cx); + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_b.peer_id().unwrap(), + cx, + ); }); } @@ -206,7 +265,9 @@ async fn test_auto_watch_toggle_off_leaves_tabs_open( ) { let mut server = TestServer::start(executor.clone()).await; let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await; - let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a); + let (workspace_a, user_a) = setup + .client_a + .build_workspace(&setup.user_a_project, user_a); workspace_a.update_in(user_a, |workspace, window, cx| { workspace.toggle_auto_watch(window, cx); @@ -215,27 +276,177 @@ async fn test_auto_watch_toggle_off_leaves_tabs_open( executor.run_until_parked(); workspace_a.update(user_a, |workspace, cx| { - assert_active_matches_title(workspace, "user_b's screen", cx); + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_b.peer_id().unwrap(), + cx, + ); + }); + + workspace_a.update_in(user_a, |workspace, window, cx| { + workspace.toggle_auto_watch(window, cx); + }); + + workspace_a.update(user_a, |workspace, cx| { + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_b.peer_id().unwrap(), + cx, + ); + }); +} + +#[gpui::test] +async fn test_auto_watch_reopens_screen_share_from_returning_channel_participant( + executor: BackgroundExecutor, + user_a: &mut TestAppContext, + user_b: &mut TestAppContext, + user_c: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let setup = setup_auto_watch_late_joiner_test(&mut server, user_a, user_b, user_c).await; + let (workspace_a, user_a) = setup + .client_a + .build_workspace(&setup.user_a_project, user_a); + let (workspace_b, user_b) = setup + .client_b + .build_workspace(&setup.user_b_project, user_b); + + workspace_a.update_in(user_a, |workspace, window, cx| { + workspace.toggle_auto_watch(window, cx); + }); + workspace_b.update_in(user_b, |workspace, window, cx| { + workspace.toggle_auto_watch(window, cx); + }); + executor.run_until_parked(); + + let active_call_c = user_c.read(ActiveCall::global); + active_call_c + .update(user_c, |call, cx| call.join_channel(setup.channel_id, cx)) + .await + .unwrap(); + executor.run_until_parked(); + + start_screen_share(user_c).await; + executor.run_until_parked(); + + workspace_a.update(user_a, |workspace, cx| { + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_c.peer_id().unwrap(), + cx, + ); + }); + workspace_b.update(user_b, |workspace, cx| { + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_c.peer_id().unwrap(), + cx, + ); + }); + + active_call_c + .update(user_c, |call, cx| call.hang_up(cx)) + .await + .unwrap(); + executor.run_until_parked(); + + workspace_a.update(user_a, |workspace, cx| { + assert_no_screen_share_tabs_exist( + workspace, + "user A should stop seeing user C's screen after user C hangs up", + cx, + ); }); + workspace_b.update(user_b, |workspace, cx| { + assert_no_screen_share_tabs_exist( + workspace, + "user B should stop seeing user C's screen after user C hangs up", + cx, + ); + }); + + let active_call_c = user_c.read(ActiveCall::global); + active_call_c + .update(user_c, |call, cx| call.join_channel(setup.channel_id, cx)) + .await + .unwrap(); + executor.run_until_parked(); + + start_screen_share(user_c).await; + executor.run_until_parked(); + + workspace_a.update(user_a, |workspace, cx| { + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_c.peer_id().unwrap(), + cx, + ); + }); + workspace_b.update(user_b, |workspace, cx| { + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_c.peer_id().unwrap(), + cx, + ); + }); +} + +#[gpui::test] +async fn test_auto_watch_is_disabled_when_following_collaborator( + executor: BackgroundExecutor, + user_a: &mut TestAppContext, + user_b: &mut TestAppContext, + user_c: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await; + let (workspace_a, user_a) = setup + .client_a + .build_workspace(&setup.user_a_project, user_a); + let user_b_peer_id = setup.client_b.peer_id().unwrap(); workspace_a.update_in(user_a, |workspace, window, cx| { workspace.toggle_auto_watch(window, cx); }); + start_screen_share(user_b).await; + executor.run_until_parked(); workspace_a.update(user_a, |workspace, cx| { - assert_active_matches_title(workspace, "user_b's screen", cx); + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_b.peer_id().unwrap(), + cx, + ); }); + + workspace_a.update_in(user_a, |workspace, window, cx| { + workspace.follow(user_b_peer_id, window, cx); + }); + executor.run_until_parked(); + + workspace_a.update(user_a, |workspace, _cx| { + assert_eq!(*workspace.auto_watch_state(), AutoWatch::Off); + }); +} + +#[track_caller] +fn assert_no_screen_share_tabs_exist(workspace: &Workspace, message: &str, cx: &App) { + let has_shared_screen_tab = workspace + .active_pane() + .read(cx) + .items() + .any(|item| item.downcast::().is_some()); + assert!(!has_shared_screen_tab, "{message}"); } #[track_caller] -fn assert_active_matches_title(workspace: &Workspace, expected_title: &str, cx: &App) { +fn assert_active_item_is_screen_share_for_peer(workspace: &Workspace, peer_id: PeerId, cx: &App) { let active_item = workspace.active_item(cx).expect("no active item"); - assert_eq!( - active_item.tab_content_text(0, cx), - expected_title, - "expected active item to be '{}'", - expected_title - ); + let shared_screen = active_item + .downcast::() + .expect("expected active item to be a shared screen"); + assert_eq!(shared_screen.read(cx).peer_id, peer_id); } async fn start_screen_share(cx: &mut TestAppContext) { @@ -260,6 +471,7 @@ async fn start_screen_share(cx: &mut TestAppContext) { .unwrap(); } +#[track_caller] fn stop_screen_share(cx: &mut TestAppContext) { let active_call = cx.read(ActiveCall::global); active_call diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index cea3806edb3e01..5fe6d839569b63 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -2913,6 +2913,13 @@ impl CollabPanel { if show_auto_watch || show_copy { Some( h_flex() + .when_some(channel_link, |this, channel_link| { + this.child( + CopyButton::new("copy-channel-link", channel_link) + .visible_on_hover("section-header") + .tooltip_label("Copy Channel Link"), + ) + }) .when(has_auto_watch_flag, |this| { this.child( IconButton::new( @@ -2952,13 +2959,6 @@ impl CollabPanel { )), ) }) - .when_some(channel_link, |this, channel_link| { - this.child( - CopyButton::new("copy-channel-link", channel_link) - .visible_on_hover("section-header") - .tooltip_label("Copy Channel Link"), - ) - }) .into_any_element(), ) } else { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 9cc1fa30865f81..79b7e28ffb34ff 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -5742,6 +5742,7 @@ impl Workspace { .insert(pane.downgrade(), leader_id); self.unfollow(leader_id, window, cx); self.unfollow_in_pane(&pane, window, cx); + self.auto_watch = AutoWatch::Off; self.follower_states.insert( leader_id, FollowerState { From e1a46f9256354971248b0696ed34641b48953907 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Fri, 8 May 2026 10:06:44 +0200 Subject: [PATCH 31/33] gpui: Use `SharedString::new_static` within `From` impls for `ElementId` where possible (#56139) Horror of a PR title but could not think of anything better here. Release Notes: - N/A --------- Co-authored-by: Kirill Bulatov --- crates/gpui/src/window.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 46b1ab64a188ca..659a34dec9bec1 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -5764,7 +5764,7 @@ impl From> for ElementId { impl From<&'static str> for ElementId { fn from(name: &'static str) -> Self { - ElementId::Name(name.into()) + ElementId::Name(SharedString::new_static(name)) } } @@ -5776,13 +5776,13 @@ impl<'a> From<&'a FocusHandle> for ElementId { impl From<(&'static str, EntityId)> for ElementId { fn from((name, id): (&'static str, EntityId)) -> Self { - ElementId::NamedInteger(name.into(), id.as_u64()) + ElementId::NamedInteger(SharedString::new_static(name), id.as_u64()) } } impl From<(&'static str, usize)> for ElementId { fn from((name, id): (&'static str, usize)) -> Self { - ElementId::NamedInteger(name.into(), id as u64) + ElementId::NamedInteger(SharedString::new_static(name), id as u64) } } @@ -5794,7 +5794,7 @@ impl From<(SharedString, usize)> for ElementId { impl From<(&'static str, u64)> for ElementId { fn from((name, id): (&'static str, u64)) -> Self { - ElementId::NamedInteger(name.into(), id) + ElementId::NamedInteger(SharedString::new_static(name), id) } } @@ -5806,7 +5806,7 @@ impl From for ElementId { impl From<(&'static str, u32)> for ElementId { fn from((name, id): (&'static str, u32)) -> Self { - ElementId::NamedInteger(name.into(), id.into()) + ElementId::NamedInteger(SharedString::new_static(name), u64::from(id)) } } From e727080af232cec481bafb2d080585091c3f5db7 Mon Sep 17 00:00:00 2001 From: Gabriel Linder Date: Fri, 8 May 2026 10:09:42 +0200 Subject: [PATCH 32/33] Update Mistral provider docs following #55443 (#56133) Update Mistral provider docs following #55443 Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests **and docs** cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Release Notes: - N/A or Added/Fixed/Improved ... Signed-off-by: Gabriel Linder --- docs/src/ai/llm-providers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index e1b5a50779fb30..cf130c35326616 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -362,7 +362,7 @@ Zed will also use the `MISTRAL_API_KEY` environment variable if it's defined. #### Custom Models {#mistral-custom-models} -The Zed agent comes pre-configured with several Mistral models (codestral-latest, mistral-large-latest, mistral-medium-latest, mistral-small-latest, open-mistral-nemo, and open-codestral-mamba). +The Zed agent comes pre-configured to use the latest version for common Mistral models (Large, Medium, Small, Codestral, Devstral, and others). All the default models support tool use. If you wish to use alternate models or customize their parameters, you can do so by adding the following to your Zed settings file ([how to edit](../configuring-zed.md#settings-files)): From c8f002686780fdfcdb9301bf234d0c4bf4563800 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Fri, 8 May 2026 11:30:34 +0200 Subject: [PATCH 33/33] gpui: Remove unsound await_on_background helper (#56132) The function is unsound due to the classic fact that one can leak tasks, sidestepping the blocking drop behavior resulting in a use after free. Release Notes: - N/A or Added/Fixed/Improved ... --- crates/diagnostics/src/diagnostics.rs | 70 ++-- crates/gpui/src/executor.rs | 54 --- crates/language/src/language.rs | 30 +- crates/languages/src/bash.rs | 93 +++-- crates/languages/src/c.rs | 140 +++---- crates/languages/src/css.rs | 75 ++-- crates/languages/src/eslint.rs | 96 ++--- crates/languages/src/go.rs | 129 +++--- crates/languages/src/json.rs | 163 ++++---- crates/languages/src/python.rs | 555 ++++++++++++++------------ crates/languages/src/rust.rs | 147 +++---- crates/languages/src/tailwind.rs | 79 ++-- crates/languages/src/tailwindcss.rs | 79 ++-- crates/languages/src/typescript.rs | 109 ++--- crates/languages/src/vtsls.rs | 85 ++-- crates/languages/src/yaml.rs | 80 ++-- crates/project/src/lsp_store.rs | 8 +- 17 files changed, 1027 insertions(+), 965 deletions(-) diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 4ee8259dd695d8..642d16b6e25476 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -1050,47 +1050,41 @@ async fn heuristic_syntactic_expand( let node_range = node_start..node_end; let row_count = node_end.row - node_start.row + 1; let mut ancestor_range = None; - cx.background_executor() - .await_on_background(async { - // Stop if we've exceeded the row count or reached an outline node. Then, find the interval - // of node children which contains the query range. For example, this allows just returning - // the header of a declaration rather than the entire declaration. - if row_count > max_row_count || outline_range == Some(node_range.clone()) { - let mut cursor = node.walk(); - let mut included_child_start = None; - let mut included_child_end = None; - let mut previous_end = node_start; - if cursor.goto_first_child() { - loop { - let child_node = cursor.node(); - let child_range = - previous_end..Point::from_ts_point(child_node.end_position()); - if included_child_start.is_none() - && child_range.contains(&input_range.start) - { - included_child_start = Some(child_range.start); - } - if child_range.contains(&input_range.end) { - included_child_end = Some(child_range.end); - } - previous_end = child_range.end; - if !cursor.goto_next_sibling() { - break; - } - } + // Stop if we've exceeded the row count or reached an outline node. Then, find the interval + // of node children which contains the query range. For example, this allows just returning + // the header of a declaration rather than the entire declaration. + if row_count > max_row_count || outline_range == Some(node_range.clone()) { + let mut cursor = node.walk(); + let mut included_child_start = None; + let mut included_child_end = None; + let mut previous_end = node_start; + if cursor.goto_first_child() { + loop { + let child_node = cursor.node(); + let child_range = previous_end..Point::from_ts_point(child_node.end_position()); + if included_child_start.is_none() && child_range.contains(&input_range.start) { + included_child_start = Some(child_range.start); } - let end = included_child_end.unwrap_or(node_range.end); - if let Some(start) = included_child_start { - let row_count = end.row - start.row; - if row_count < max_row_count { - ancestor_range = Some(Some(RangeInclusive::new(start.row, end.row))); - return; - } + if child_range.contains(&input_range.end) { + included_child_end = Some(child_range.end); + } + previous_end = child_range.end; + if !cursor.goto_next_sibling() { + break; } - ancestor_range = Some(None); } - }) - .await; + } + let end = included_child_end.unwrap_or(node_range.end); + if let Some(start) = included_child_start { + let row_count = end.row - start.row; + if row_count < max_row_count { + ancestor_range = Some(Some(RangeInclusive::new(start.row, end.row))); + } + } + if ancestor_range.is_none() { + ancestor_range = Some(None); + } + } if let Some(node) = ancestor_range { return node; } diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index 07f1667b6201e4..c1afce810738aa 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -115,60 +115,6 @@ impl BackgroundExecutor { } } - /// Enqueues the given future to be run to completion on a background thread and blocking the current task on it. - /// - /// This allows to spawn background work that borrows from its scope. Note that the supplied future will run to - /// completion before the current task is resumed, even if the current task is slated for cancellation. - pub async fn await_on_background(&self, future: impl Future + Send) -> R - where - R: Send, - { - use crate::RunnableMeta; - use parking_lot::{Condvar, Mutex}; - - struct NotifyOnDrop<'a>(&'a (Condvar, Mutex)); - - impl Drop for NotifyOnDrop<'_> { - fn drop(&mut self) { - *self.0.1.lock() = true; - self.0.0.notify_all(); - } - } - - struct WaitOnDrop<'a>(&'a (Condvar, Mutex)); - - impl Drop for WaitOnDrop<'_> { - fn drop(&mut self) { - let mut done = self.0.1.lock(); - if !*done { - self.0.0.wait(&mut done); - } - } - } - - let dispatcher = self.dispatcher.clone(); - let location = core::panic::Location::caller(); - - let pair = &(Condvar::new(), Mutex::new(false)); - let _wait_guard = WaitOnDrop(pair); - - let (runnable, task) = unsafe { - async_task::Builder::new() - .metadata(RunnableMeta { location }) - .spawn_unchecked( - move |_| async { - let _notify_guard = NotifyOnDrop(pair); - future.await - }, - move |runnable| { - dispatcher.dispatch(runnable, Priority::default()); - }, - ) - }; - runnable.schedule(); - task.await - } - /// Scoped lets you start a number of tasks and waits /// for all of them to complete before returning. pub async fn scoped<'scope, F>(&self, scheduler: F) diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 8bfc4efb1ffa00..a6e05fb586c37a 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -623,8 +623,8 @@ pub trait LspInstaller { &self, _version: &Self::BinaryVersion, _container_dir: &PathBuf, - _delegate: &dyn LspAdapterDelegate, - ) -> impl Send + Future> { + _delegate: &Arc, + ) -> impl Send + Future> + use { async { None } } @@ -632,8 +632,8 @@ pub trait LspInstaller { &self, latest_version: Self::BinaryVersion, container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> impl Send + Future>; + _delegate: &Arc, + ) -> impl Send + Future> + use; fn cached_server_binary( &self, @@ -686,11 +686,7 @@ where if let Some(binary) = cx .background_executor() - .await_on_background(self.check_if_version_installed( - &latest_version, - &container_dir, - delegate.as_ref(), - )) + .spawn(self.check_if_version_installed(&latest_version, &container_dir, &delegate)) .await { log::debug!("language server {:?} is already installed", name.0); @@ -701,11 +697,7 @@ where delegate.update_status(name.clone(), BinaryStatus::Downloading); let binary = cx .background_executor() - .await_on_background(self.fetch_server_binary( - latest_version, - container_dir, - delegate.as_ref(), - )) + .spawn(self.fetch_server_binary(latest_version, container_dir, delegate)) .await; delegate.update_status(name.clone(), BinaryStatus::None); @@ -1421,13 +1413,15 @@ impl LspInstaller for FakeLspAdapter { Some(self.language_server_binary.clone()) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, _: (), _: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Result { - unreachable!(); + _: &Arc, + ) -> impl Send + Future> + use<> { + async { + unreachable!(); + } } async fn cached_server_binary( diff --git a/crates/languages/src/bash.rs b/crates/languages/src/bash.rs index a002968fa4041b..438090e2aa9db9 100644 --- a/crates/languages/src/bash.rs +++ b/crates/languages/src/bash.rs @@ -6,7 +6,7 @@ use lsp::LanguageServerBinary; use node_runtime::{NodeRuntime, VersionStrategy}; use project::ContextProviderWithTasks; use semver::Version; -use std::{path::PathBuf, vec}; +use std::{future::Future, path::PathBuf, sync::Arc, vec}; use task::{TaskTemplate, TaskTemplates, VariableName}; use util::{ResultExt, maybe}; @@ -90,35 +90,41 @@ impl LspInstaller for BashLspAdapter { }) } - async fn check_if_version_installed( + fn check_if_version_installed( &self, version: &Self::BinaryVersion, container_dir: &PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Option { - let server_path = container_dir - .join("node_modules") - .join(Self::NODE_MODULE_RELATIVE_SERVER_PATH); - - let should_install_language_server = self - .node - .should_install_npm_package( - Self::PACKAGE_NAME, - &server_path, - container_dir, - VersionStrategy::Latest(version), - ) - .await; + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); + let version = version.clone(); + let container_dir = container_dir.clone(); + let delegate = delegate.clone(); + + async move { + let server_path = container_dir + .join("node_modules") + .join(Self::NODE_MODULE_RELATIVE_SERVER_PATH); - if should_install_language_server { - None - } else { - let env = delegate.shell_env().await; - Some(LanguageServerBinary { - path: self.node.binary_path().await.ok()?, - env: Some(env), - arguments: vec![server_path.into(), "start".into()], - }) + let should_install_language_server = node + .should_install_npm_package( + Self::PACKAGE_NAME, + &server_path, + &container_dir, + VersionStrategy::Latest(&version), + ) + .await; + + if should_install_language_server { + None + } else { + let env = delegate.shell_env().await; + Some(LanguageServerBinary { + path: node.binary_path().await.ok()?, + env: Some(env), + arguments: vec![server_path.into(), "start".into()], + }) + } } } @@ -133,29 +139,34 @@ impl LspInstaller for BashLspAdapter { .await } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: Self::BinaryVersion, container_dir: std::path::PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let server_path = container_dir - .join("node_modules") - .join(Self::NODE_MODULE_RELATIVE_SERVER_PATH); + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); + let delegate = delegate.clone(); - self.node - .npm_install_packages( + async move { + let server_path = container_dir + .join("node_modules") + .join(Self::NODE_MODULE_RELATIVE_SERVER_PATH); + let latest_version = latest_version.to_string(); + + node.npm_install_packages( &container_dir, - &[(Self::PACKAGE_NAME, &latest_version.to_string())], + &[(Self::PACKAGE_NAME, latest_version.as_str())], ) .await?; - let env = delegate.shell_env().await; - Ok(LanguageServerBinary { - path: self.node.binary_path().await?, - env: Some(env), - arguments: vec![server_path.into(), "start".into()], - }) + let env = delegate.shell_env().await; + Ok(LanguageServerBinary { + path: node.binary_path().await?, + env: Some(env), + arguments: vec![server_path.into(), "start".into()], + }) + } } } diff --git a/crates/languages/src/c.rs b/crates/languages/src/c.rs index 6585863f993f30..d2e92904c6df9c 100644 --- a/crates/languages/src/c.rs +++ b/crates/languages/src/c.rs @@ -9,7 +9,7 @@ use lsp::{InitializeParams, LanguageServerBinary, LanguageServerName}; use project::lsp_store::clangd_ext; use serde_json::json; use smol::fs; -use std::{env::consts, path::PathBuf, sync::Arc}; +use std::{env::consts, future::Future, path::PathBuf, sync::Arc}; use util::{ResultExt, fs::remove_matching, maybe, merge_json_value_into}; pub struct CLspAdapter; @@ -66,82 +66,88 @@ impl LspInstaller for CLspAdapter { }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, version: GitHubLspBinaryVersion, container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - ensure_arch_compatibility()?; + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); - let GitHubLspBinaryVersion { - name, - url, - digest: expected_digest, - } = version; - let version_dir = container_dir.join(format!("clangd_{name}")); - let binary_path = version_dir - .join("bin") - .join(format!("clangd{}", consts::EXE_SUFFIX)); + async move { + ensure_arch_compatibility()?; - let binary = LanguageServerBinary { - path: binary_path.clone(), - env: None, - arguments: Default::default(), - }; - - let metadata_path = version_dir.join("metadata"); - let metadata = GithubBinaryMetadata::read_from_file(&metadata_path) - .await - .ok(); - if let Some(metadata) = metadata { - let validity_check = async || { - delegate - .try_exec(LanguageServerBinary { - path: binary_path.clone(), - arguments: vec!["--version".into()], - env: None, - }) - .await - .inspect_err(|err| { - log::warn!("Unable to run {binary_path:?} asset, redownloading: {err:#}",) - }) + let GitHubLspBinaryVersion { + name, + url, + digest: expected_digest, + } = version; + let version_dir = container_dir.join(format!("clangd_{name}")); + let binary_path = version_dir + .join("bin") + .join(format!("clangd{}", consts::EXE_SUFFIX)); + + let binary = LanguageServerBinary { + path: binary_path.clone(), + env: None, + arguments: Default::default(), }; - if let (Some(actual_digest), Some(expected_digest)) = - (&metadata.digest, &expected_digest) - { - if actual_digest == expected_digest { - if validity_check().await.is_ok() { - return Ok(binary); + + let metadata_path = version_dir.join("metadata"); + let metadata = GithubBinaryMetadata::read_from_file(&metadata_path) + .await + .ok(); + if let Some(metadata) = metadata { + let validity_check = async || { + delegate + .try_exec(LanguageServerBinary { + path: binary_path.clone(), + arguments: vec!["--version".into()], + env: None, + }) + .await + .inspect_err(|err| { + log::warn!( + "Unable to run {binary_path:?} asset, redownloading: {err:#}", + ) + }) + }; + if let (Some(actual_digest), Some(expected_digest)) = + (&metadata.digest, &expected_digest) + { + if actual_digest == expected_digest { + if validity_check().await.is_ok() { + return Ok(binary); + } + } else { + log::info!( + "SHA-256 mismatch for {binary_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}" + ); } - } else { - log::info!( - "SHA-256 mismatch for {binary_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}" - ); + } else if validity_check().await.is_ok() { + return Ok(binary); } - } else if validity_check().await.is_ok() { - return Ok(binary); } - } - download_server_binary( - &*delegate.http_client(), - &url, - expected_digest.as_deref(), - &container_dir, - AssetKind::Zip, - ) - .await?; - remove_matching(&container_dir, |entry| entry != version_dir).await; - GithubBinaryMetadata::write_to_file( - &GithubBinaryMetadata { - metadata_version: 1, - digest: expected_digest, - }, - &metadata_path, - ) - .await?; + download_server_binary( + &*delegate.http_client(), + &url, + expected_digest.as_deref(), + &container_dir, + AssetKind::Zip, + ) + .await?; + remove_matching(&container_dir, |entry| entry != version_dir).await; + GithubBinaryMetadata::write_to_file( + &GithubBinaryMetadata { + metadata_version: 1, + digest: expected_digest, + }, + &metadata_path, + ) + .await?; - Ok(binary) + Ok(binary) + } } async fn cached_server_binary( diff --git a/crates/languages/src/css.rs b/crates/languages/src/css.rs index 6a8fb730a0faa6..dfa0bc9fd3d4d9 100644 --- a/crates/languages/src/css.rs +++ b/crates/languages/src/css.rs @@ -9,6 +9,7 @@ use semver::Version; use serde_json::json; use std::{ ffi::OsString, + future::Future, path::{Path, PathBuf}, sync::Arc, }; @@ -64,55 +65,63 @@ impl LspInstaller for CssLspAdapter { }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: Self::BinaryVersion, container_dir: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Result { - let server_path = container_dir.join(SERVER_PATH); - let latest_version = latest_version.to_string(); + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); - self.node - .npm_install_packages( + async move { + let server_path = container_dir.join(SERVER_PATH); + let latest_version = latest_version.to_string(); + + node.npm_install_packages( &container_dir, &[(Self::PACKAGE_NAME, latest_version.as_str())], ) .await?; - Ok(LanguageServerBinary { - path: self.node.binary_path().await?, - env: None, - arguments: server_binary_arguments(&server_path), - }) + Ok(LanguageServerBinary { + path: node.binary_path().await?, + env: None, + arguments: server_binary_arguments(&server_path), + }) + } } - async fn check_if_version_installed( + fn check_if_version_installed( &self, version: &Self::BinaryVersion, container_dir: &PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Option { - let server_path = container_dir.join(SERVER_PATH); + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); + let version = version.clone(); + let container_dir = container_dir.clone(); - let should_install_language_server = self - .node - .should_install_npm_package( - Self::PACKAGE_NAME, - &server_path, - container_dir, - VersionStrategy::Latest(version), - ) - .await; + async move { + let server_path = container_dir.join(SERVER_PATH); - if should_install_language_server { - None - } else { - Some(LanguageServerBinary { - path: self.node.binary_path().await.ok()?, - env: None, - arguments: server_binary_arguments(&server_path), - }) + let should_install_language_server = node + .should_install_npm_package( + Self::PACKAGE_NAME, + &server_path, + &container_dir, + VersionStrategy::Latest(&version), + ) + .await; + + if should_install_language_server { + None + } else { + Some(LanguageServerBinary { + path: node.binary_path().await.ok()?, + env: None, + arguments: server_binary_arguments(&server_path), + }) + } } } diff --git a/crates/languages/src/eslint.rs b/crates/languages/src/eslint.rs index e9b94380191731..063cf85affd96f 100644 --- a/crates/languages/src/eslint.rs +++ b/crates/languages/src/eslint.rs @@ -17,6 +17,7 @@ use settings::SettingsLocation; use smol::{fs, stream::StreamExt}; use std::{ ffi::OsString, + future::Future, path::{Path, PathBuf}, sync::Arc, }; @@ -99,60 +100,63 @@ impl LspInstaller for EsLintLspAdapter { }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, version: GitHubLspBinaryVersion, container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let destination_path = Self::build_destination_path(&container_dir); - let server_path = destination_path.join(Self::SERVER_PATH); - - if fs::metadata(&server_path).await.is_err() { - remove_matching(&container_dir, |_| true).await; - - download_server_binary( - &*delegate.http_client(), - &version.url, - None, - &destination_path, - Self::GITHUB_ASSET_KIND, - ) - .await?; - - let mut dir = fs::read_dir(&destination_path).await?; - let first = dir.next().await.context("missing first file")??; - let repo_root = destination_path.join("vscode-eslint"); - fs::rename(first.path(), &repo_root).await?; - - #[cfg(target_os = "windows")] - { - handle_symlink( - repo_root.join("$shared"), - repo_root.join("client").join("src").join("shared"), - ) - .await?; - handle_symlink( - repo_root.join("$shared"), - repo_root.join("server").join("src").join("shared"), + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); + let node = self.node.clone(); + + async move { + let destination_path = Self::build_destination_path(&container_dir); + let server_path = destination_path.join(Self::SERVER_PATH); + + if fs::metadata(&server_path).await.is_err() { + remove_matching(&container_dir, |_| true).await; + + download_server_binary( + &*delegate.http_client(), + &version.url, + None, + &destination_path, + Self::GITHUB_ASSET_KIND, ) .await?; - } - self.node - .run_npm_subcommand(Some(&repo_root), "install", &[]) - .await?; + let mut dir = fs::read_dir(&destination_path).await?; + let first = dir.next().await.context("missing first file")??; + let repo_root = destination_path.join("vscode-eslint"); + fs::rename(first.path(), &repo_root).await?; - self.node - .run_npm_subcommand(Some(&repo_root), "run-script", &["compile"]) - .await?; - } + #[cfg(target_os = "windows")] + { + handle_symlink( + repo_root.join("$shared"), + repo_root.join("client").join("src").join("shared"), + ) + .await?; + handle_symlink( + repo_root.join("$shared"), + repo_root.join("server").join("src").join("shared"), + ) + .await?; + } - Ok(LanguageServerBinary { - path: self.node.binary_path().await?, - env: None, - arguments: eslint_server_binary_arguments(&server_path), - }) + node.run_npm_subcommand(Some(&repo_root), "install", &[]) + .await?; + + node.run_npm_subcommand(Some(&repo_root), "run-script", &["compile"]) + .await?; + } + + Ok(LanguageServerBinary { + path: node.binary_path().await?, + env: None, + arguments: eslint_server_binary_arguments(&server_path), + }) + } } async fn cached_server_binary( diff --git a/crates/languages/src/go.rs b/crates/languages/src/go.rs index f4d0ce5f4d4b55..3bedd62b8e67ec 100644 --- a/crates/languages/src/go.rs +++ b/crates/languages/src/go.rs @@ -19,6 +19,7 @@ use smol::fs; use std::{ borrow::Cow, ffi::{OsStr, OsString}, + future::Future, ops::Range, path::{Path, PathBuf}, process::Output, @@ -117,75 +118,79 @@ impl LspInstaller for GoLspAdapter { }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, version: Option, container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let go = delegate.which("go".as_ref()).await.unwrap_or("go".into()); - let go_version_output = util::command::new_command(&go) - .args(["version"]) - .output() - .await - .context("failed to get go version via `go version` command`")?; - let go_version = parse_version_output(&go_version_output)?; - - if let Some(version) = version { - let binary_path = container_dir.join(format!("gopls_{version}_go_{go_version}")); - if let Ok(metadata) = fs::metadata(&binary_path).await - && metadata.is_file() - { - remove_matching(&container_dir, |entry| { - entry != binary_path && entry.file_name() != Some(OsStr::new("gobin")) - }) - .await; + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); + + async move { + let go = delegate.which("go".as_ref()).await.unwrap_or("go".into()); + let go_version_output = util::command::new_command(&go) + .args(["version"]) + .output() + .await + .context("failed to get go version via `go version` command`")?; + let go_version = parse_version_output(&go_version_output)?; + + if let Some(version) = version { + let binary_path = container_dir.join(format!("gopls_{version}_go_{go_version}")); + if let Ok(metadata) = fs::metadata(&binary_path).await + && metadata.is_file() + { + remove_matching(&container_dir, |entry| { + entry != binary_path && entry.file_name() != Some(OsStr::new("gobin")) + }) + .await; - return Ok(LanguageServerBinary { - path: binary_path.to_path_buf(), - arguments: server_binary_arguments(), - env: None, - }); + return Ok(LanguageServerBinary { + path: binary_path.to_path_buf(), + arguments: server_binary_arguments(), + env: None, + }); + } + } else if let Some(path) = get_cached_server_binary(&container_dir).await { + return Ok(path); } - } else if let Some(path) = get_cached_server_binary(&container_dir).await { - return Ok(path); - } - let gobin_dir = container_dir.join("gobin"); - fs::create_dir_all(&gobin_dir).await?; - let install_output = util::command::new_command(go) - .env("GO111MODULE", "on") - .env("GOBIN", &gobin_dir) - .args(["install", "golang.org/x/tools/gopls@latest"]) - .output() - .await?; - - if !install_output.status.success() { - log::error!( - "failed to install gopls via `go install`. stdout: {:?}, stderr: {:?}", - String::from_utf8_lossy(&install_output.stdout), - String::from_utf8_lossy(&install_output.stderr) - ); - anyhow::bail!( - "failed to install gopls with `go install`. Is `go` installed and in the PATH? Check logs for more information." - ); - } + let gobin_dir = container_dir.join("gobin"); + fs::create_dir_all(&gobin_dir).await?; + let install_output = util::command::new_command(go) + .env("GO111MODULE", "on") + .env("GOBIN", &gobin_dir) + .args(["install", "golang.org/x/tools/gopls@latest"]) + .output() + .await?; + + if !install_output.status.success() { + log::error!( + "failed to install gopls via `go install`. stdout: {:?}, stderr: {:?}", + String::from_utf8_lossy(&install_output.stdout), + String::from_utf8_lossy(&install_output.stderr) + ); + anyhow::bail!( + "failed to install gopls with `go install`. Is `go` installed and in the PATH? Check logs for more information." + ); + } - let installed_binary_path = gobin_dir.join(BINARY); - let version_output = util::command::new_command(&installed_binary_path) - .arg("version") - .output() - .await - .context("failed to run installed gopls binary")?; - let gopls_version = parse_version_output(&version_output)?; - let binary_path = container_dir.join(format!("gopls_{gopls_version}_go_{go_version}")); - fs::rename(&installed_binary_path, &binary_path).await?; - - Ok(LanguageServerBinary { - path: binary_path.to_path_buf(), - arguments: server_binary_arguments(), - env: None, - }) + let installed_binary_path = gobin_dir.join(BINARY); + let version_output = util::command::new_command(&installed_binary_path) + .arg("version") + .output() + .await + .context("failed to run installed gopls binary")?; + let gopls_version = parse_version_output(&version_output)?; + let binary_path = container_dir.join(format!("gopls_{gopls_version}_go_{go_version}")); + fs::rename(&installed_binary_path, &binary_path).await?; + + Ok(LanguageServerBinary { + path: binary_path.to_path_buf(), + arguments: server_binary_arguments(), + env: None, + }) + } } async fn cached_server_binary( diff --git a/crates/languages/src/json.rs b/crates/languages/src/json.rs index b1bcd0043e5207..9cd6c1565ad46d 100644 --- a/crates/languages/src/json.rs +++ b/crates/languages/src/json.rs @@ -24,6 +24,7 @@ use std::{ borrow::Cow, env::consts, ffi::OsString, + future::Future, path::{Path, PathBuf}, str::FromStr, sync::Arc, @@ -176,56 +177,64 @@ impl LspInstaller for JsonLspAdapter { }) } - async fn check_if_version_installed( + fn check_if_version_installed( &self, version: &Self::BinaryVersion, container_dir: &PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Option { - let server_path = container_dir.join(SERVER_PATH); - - let should_install_language_server = self - .node - .should_install_npm_package( - Self::PACKAGE_NAME, - &server_path, - container_dir, - VersionStrategy::Latest(version), - ) - .await; - - if should_install_language_server { - None - } else { - Some(LanguageServerBinary { - path: self.node.binary_path().await.ok()?, - env: None, - arguments: server_binary_arguments(&server_path), - }) + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); + let version = version.clone(); + let container_dir = container_dir.clone(); + + async move { + let server_path = container_dir.join(SERVER_PATH); + + let should_install_language_server = node + .should_install_npm_package( + Self::PACKAGE_NAME, + &server_path, + &container_dir, + VersionStrategy::Latest(&version), + ) + .await; + + if should_install_language_server { + None + } else { + Some(LanguageServerBinary { + path: node.binary_path().await.ok()?, + env: None, + arguments: server_binary_arguments(&server_path), + }) + } } } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: Self::BinaryVersion, container_dir: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Result { - let server_path = container_dir.join(SERVER_PATH); - let latest_version = latest_version.to_string(); + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); - self.node - .npm_install_packages( + async move { + let server_path = container_dir.join(SERVER_PATH); + let latest_version = latest_version.to_string(); + + node.npm_install_packages( &container_dir, &[(Self::PACKAGE_NAME, latest_version.as_str())], ) .await?; - Ok(LanguageServerBinary { - path: self.node.binary_path().await?, - env: None, - arguments: server_binary_arguments(&server_path), - }) + Ok(LanguageServerBinary { + path: node.binary_path().await?, + env: None, + arguments: server_binary_arguments(&server_path), + }) + } } async fn cached_server_binary( @@ -478,51 +487,55 @@ impl LspInstaller for NodeVersionAdapter { }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: GitHubLspBinaryVersion, container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let version = &latest_version; - let destination_path = container_dir.join(format!( - "{}-{}{}", - Self::SERVER_NAME, - version.name, - std::env::consts::EXE_SUFFIX - )); - let destination_container_path = - container_dir.join(format!("{}-{}-tmp", Self::SERVER_NAME, version.name)); - if fs::metadata(&destination_path).await.is_err() { - let mut response = delegate - .http_client() - .get(&version.url, Default::default(), true) - .await - .context("downloading release")?; - if version.url.ends_with(".zip") { - extract_zip(&destination_container_path, response.body_mut()).await?; - } else if version.url.ends_with(".tar.gz") { - let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut())); - let archive = Archive::new(decompressed_bytes); - archive.unpack(&destination_container_path).await?; - } + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); + + async move { + let version = &latest_version; + let destination_path = container_dir.join(format!( + "{}-{}{}", + Self::SERVER_NAME, + version.name, + std::env::consts::EXE_SUFFIX + )); + let destination_container_path = + container_dir.join(format!("{}-{}-tmp", Self::SERVER_NAME, version.name)); + if fs::metadata(&destination_path).await.is_err() { + let mut response = delegate + .http_client() + .get(&version.url, Default::default(), true) + .await + .context("downloading release")?; + if version.url.ends_with(".zip") { + extract_zip(&destination_container_path, response.body_mut()).await?; + } else if version.url.ends_with(".tar.gz") { + let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut())); + let archive = Archive::new(decompressed_bytes); + archive.unpack(&destination_container_path).await?; + } - fs::copy( - destination_container_path.join(format!( - "{}{}", - Self::SERVER_NAME, - std::env::consts::EXE_SUFFIX - )), - &destination_path, - ) - .await?; - remove_matching(&container_dir, |entry| entry != destination_path).await; + fs::copy( + destination_container_path.join(format!( + "{}{}", + Self::SERVER_NAME, + std::env::consts::EXE_SUFFIX + )), + &destination_path, + ) + .await?; + remove_matching(&container_dir, |entry| entry != destination_path).await; + } + Ok(LanguageServerBinary { + path: destination_path, + env: None, + arguments: Default::default(), + }) } - Ok(LanguageServerBinary { - path: destination_path, - env: None, - arguments: Default::default(), - }) } async fn cached_server_binary( diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 26e96789a0d4b6..483430bd75d5ee 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -1,5 +1,5 @@ +use anyhow::Result; use anyhow::{Context as _, ensure}; -use anyhow::{Result, anyhow}; use async_trait::async_trait; use collections::HashMap; use futures::future::BoxFuture; @@ -45,6 +45,7 @@ use std::str::FromStr; use std::{ borrow::Cow, fmt::Write, + future::Future, path::{Path, PathBuf}, sync::Arc, }; @@ -447,92 +448,98 @@ impl LspInstaller for TyLspAdapter { None } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: Self::BinaryVersion, container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let GitHubLspBinaryVersion { - name, - url, - digest: expected_digest, - } = latest_version; - let destination_path = container_dir.join(format!("ty-{name}")); - - async_fs::create_dir_all(&destination_path).await?; - - let server_path = match Self::GITHUB_ASSET_KIND { - AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => destination_path - .join(Self::build_asset_name()?.0) - .join("ty"), - AssetKind::Zip => destination_path.clone().join("ty.exe"), - }; + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); - let binary = LanguageServerBinary { - path: server_path.clone(), - env: None, - arguments: vec!["server".into()], - }; + async move { + let GitHubLspBinaryVersion { + name, + url, + digest: expected_digest, + } = latest_version; + let destination_path = container_dir.join(format!("ty-{name}")); - let metadata_path = destination_path.with_extension("metadata"); - let metadata = GithubBinaryMetadata::read_from_file(&metadata_path) - .await - .ok(); - if let Some(metadata) = metadata { - let validity_check = async || { - delegate - .try_exec(LanguageServerBinary { - path: server_path.clone(), - arguments: vec!["--version".into()], - env: None, - }) - .await - .inspect_err(|err| { - log::warn!("Unable to run {server_path:?} asset, redownloading: {err:#}",) - }) + async_fs::create_dir_all(&destination_path).await?; + + let server_path = match Self::GITHUB_ASSET_KIND { + AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => destination_path + .join(Self::build_asset_name()?.0) + .join("ty"), + AssetKind::Zip => destination_path.clone().join("ty.exe"), }; - if let (Some(actual_digest), Some(expected_digest)) = - (&metadata.digest, &expected_digest) - { - if actual_digest == expected_digest { - if validity_check().await.is_ok() { - return Ok(binary); + + let binary = LanguageServerBinary { + path: server_path.clone(), + env: None, + arguments: vec!["server".into()], + }; + + let metadata_path = destination_path.with_extension("metadata"); + let metadata = GithubBinaryMetadata::read_from_file(&metadata_path) + .await + .ok(); + if let Some(metadata) = metadata { + let validity_check = async || { + delegate + .try_exec(LanguageServerBinary { + path: server_path.clone(), + arguments: vec!["--version".into()], + env: None, + }) + .await + .inspect_err(|err| { + log::warn!( + "Unable to run {server_path:?} asset, redownloading: {err:#}", + ) + }) + }; + if let (Some(actual_digest), Some(expected_digest)) = + (&metadata.digest, &expected_digest) + { + if actual_digest == expected_digest { + if validity_check().await.is_ok() { + return Ok(binary); + } + } else { + log::info!( + "SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}" + ); } - } else { - log::info!( - "SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}" - ); + } else if validity_check().await.is_ok() { + return Ok(binary); } - } else if validity_check().await.is_ok() { - return Ok(binary); } - } - download_server_binary( - &*delegate.http_client(), - &url, - expected_digest.as_deref(), - &destination_path, - Self::GITHUB_ASSET_KIND, - ) - .await?; - make_file_executable(&server_path).await?; - remove_matching(&container_dir, |path| path != destination_path).await; - GithubBinaryMetadata::write_to_file( - &GithubBinaryMetadata { - metadata_version: 1, - digest: expected_digest, - }, - &metadata_path, - ) - .await?; + download_server_binary( + &*delegate.http_client(), + &url, + expected_digest.as_deref(), + &destination_path, + Self::GITHUB_ASSET_KIND, + ) + .await?; + make_file_executable(&server_path).await?; + remove_matching(&container_dir, |path| path != destination_path).await; + GithubBinaryMetadata::write_to_file( + &GithubBinaryMetadata { + metadata_version: 1, + digest: expected_digest, + }, + &metadata_path, + ) + .await?; - Ok(LanguageServerBinary { - path: server_path, - env: None, - arguments: vec!["server".into()], - }) + Ok(LanguageServerBinary { + path: server_path, + env: None, + arguments: vec!["server".into()], + }) + } } async fn cached_server_binary( @@ -777,57 +784,67 @@ impl LspInstaller for PyrightLspAdapter { } } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: Self::BinaryVersion, container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let server_path = container_dir.join(Self::SERVER_PATH); - let latest_version = latest_version.to_string(); + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); + let node = self.node.clone(); - self.node - .npm_install_packages( + async move { + let server_path = container_dir.join(Self::SERVER_PATH); + let latest_version = latest_version.to_string(); + + node.npm_install_packages( &container_dir, &[(Self::SERVER_NAME.as_ref(), latest_version.as_str())], ) .await?; - let env = delegate.shell_env().await; - Ok(LanguageServerBinary { - path: self.node.binary_path().await?, - env: Some(env), - arguments: vec![server_path.into(), "--stdio".into()], - }) + let env = delegate.shell_env().await; + Ok(LanguageServerBinary { + path: node.binary_path().await?, + env: Some(env), + arguments: vec![server_path.into(), "--stdio".into()], + }) + } } - async fn check_if_version_installed( + fn check_if_version_installed( &self, version: &Self::BinaryVersion, container_dir: &PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Option { - let server_path = container_dir.join(Self::SERVER_PATH); - - let should_install_language_server = self - .node - .should_install_npm_package( - Self::SERVER_NAME.as_ref(), - &server_path, - container_dir, - VersionStrategy::Latest(version), - ) - .await; - - if should_install_language_server { - None - } else { - let env = delegate.shell_env().await; - Some(LanguageServerBinary { - path: self.node.binary_path().await.ok()?, - env: Some(env), - arguments: vec![server_path.into(), "--stdio".into()], - }) + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); + let node = self.node.clone(); + let version = version.clone(); + let container_dir = container_dir.clone(); + + async move { + let server_path = container_dir.join(Self::SERVER_PATH); + + let should_install_language_server = node + .should_install_npm_package( + Self::SERVER_NAME.as_ref(), + &server_path, + &container_dir, + VersionStrategy::Latest(&version), + ) + .await; + + if should_install_language_server { + None + } else { + let env = delegate.shell_env().await; + Some(LanguageServerBinary { + path: node.binary_path().await.ok()?, + env: Some(env), + arguments: vec![server_path.into(), "--stdio".into()], + }) + } } } @@ -1949,46 +1966,50 @@ impl LspInstaller for PyLspAdapter { Ok(()) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, _: (), _: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let venv = self.base_venv(delegate).await.map_err(|e| anyhow!(e))?; - let pip_path = venv.join(BINARY_DIR).join("pip3"); - ensure!( - util::command::new_command(pip_path.as_path()) - .arg("install") - .arg("python-lsp-server[all]") - .arg("--upgrade") - .output() - .await? - .status - .success(), - "python-lsp-server[all] installation failed" - ); - ensure!( - util::command::new_command(pip_path) - .arg("install") - .arg("pylsp-mypy") - .arg("--upgrade") - .output() - .await? - .status - .success(), - "pylsp-mypy installation failed" - ); - let pylsp = venv.join(BINARY_DIR).join("pylsp"); - ensure!( - delegate.which(pylsp.as_os_str()).await.is_some(), - "pylsp installation was incomplete" - ); - Ok(LanguageServerBinary { - path: pylsp, - env: None, - arguments: vec![], - }) + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); + + async move { + let venv = Self::ensure_venv(delegate.as_ref()).await?; + let pip_path = venv.join(BINARY_DIR).join("pip3"); + ensure!( + util::command::new_command(pip_path.as_path()) + .arg("install") + .arg("python-lsp-server[all]") + .arg("--upgrade") + .output() + .await? + .status + .success(), + "python-lsp-server[all] installation failed" + ); + ensure!( + util::command::new_command(pip_path) + .arg("install") + .arg("pylsp-mypy") + .arg("--upgrade") + .output() + .await? + .status + .success(), + "pylsp-mypy installation failed" + ); + let pylsp = venv.join(BINARY_DIR).join("pylsp"); + ensure!( + delegate.which(pylsp.as_os_str()).await.is_some(), + "pylsp installation was incomplete" + ); + Ok(LanguageServerBinary { + path: pylsp, + env: None, + arguments: vec![], + }) + } } async fn cached_server_binary( @@ -2229,57 +2250,67 @@ impl LspInstaller for BasedPyrightLspAdapter { } } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: Self::BinaryVersion, container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let server_path = container_dir.join(Self::SERVER_PATH); - let latest_version = latest_version.to_string(); + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); + let node = self.node.clone(); - self.node - .npm_install_packages( + async move { + let server_path = container_dir.join(Self::SERVER_PATH); + let latest_version = latest_version.to_string(); + + node.npm_install_packages( &container_dir, &[(Self::SERVER_NAME.as_ref(), latest_version.as_str())], ) .await?; - let env = delegate.shell_env().await; - Ok(LanguageServerBinary { - path: self.node.binary_path().await?, - env: Some(env), - arguments: vec![server_path.into(), "--stdio".into()], - }) + let env = delegate.shell_env().await; + Ok(LanguageServerBinary { + path: node.binary_path().await?, + env: Some(env), + arguments: vec![server_path.into(), "--stdio".into()], + }) + } } - async fn check_if_version_installed( + fn check_if_version_installed( &self, version: &Self::BinaryVersion, container_dir: &PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Option { - let server_path = container_dir.join(Self::SERVER_PATH); - - let should_install_language_server = self - .node - .should_install_npm_package( - Self::SERVER_NAME.as_ref(), - &server_path, - container_dir, - VersionStrategy::Latest(version), - ) - .await; - - if should_install_language_server { - None - } else { - let env = delegate.shell_env().await; - Some(LanguageServerBinary { - path: self.node.binary_path().await.ok()?, - env: Some(env), - arguments: vec![server_path.into(), "--stdio".into()], - }) + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); + let node = self.node.clone(); + let version = version.clone(); + let container_dir = container_dir.clone(); + + async move { + let server_path = container_dir.join(Self::SERVER_PATH); + + let should_install_language_server = node + .should_install_npm_package( + Self::SERVER_NAME.as_ref(), + &server_path, + &container_dir, + VersionStrategy::Latest(&version), + ) + .await; + + if should_install_language_server { + None + } else { + let env = delegate.shell_env().await; + Some(LanguageServerBinary { + path: node.binary_path().await.ok()?, + env: Some(env), + arguments: vec![server_path.into(), "--stdio".into()], + }) + } } } @@ -2566,89 +2597,95 @@ impl LspInstaller for RuffLspAdapter { }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: GitHubLspBinaryVersion, container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let GitHubLspBinaryVersion { - name, - url, - digest: expected_digest, - } = latest_version; - let destination_path = container_dir.join(format!("ruff-{name}")); - let server_path = match Self::GITHUB_ASSET_KIND { - AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => destination_path - .join(Self::build_asset_name()?.0) - .join("ruff"), - AssetKind::Zip => destination_path.clone().join("ruff.exe"), - }; + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); - let binary = LanguageServerBinary { - path: server_path.clone(), - env: None, - arguments: vec!["server".into()], - }; + async move { + let GitHubLspBinaryVersion { + name, + url, + digest: expected_digest, + } = latest_version; + let destination_path = container_dir.join(format!("ruff-{name}")); + let server_path = match Self::GITHUB_ASSET_KIND { + AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => destination_path + .join(Self::build_asset_name()?.0) + .join("ruff"), + AssetKind::Zip => destination_path.clone().join("ruff.exe"), + }; - let metadata_path = destination_path.with_extension("metadata"); - let metadata = GithubBinaryMetadata::read_from_file(&metadata_path) - .await - .ok(); - if let Some(metadata) = metadata { - let validity_check = async || { - delegate - .try_exec(LanguageServerBinary { - path: server_path.clone(), - arguments: vec!["--version".into()], - env: None, - }) - .await - .inspect_err(|err| { - log::warn!("Unable to run {server_path:?} asset, redownloading: {err:#}",) - }) + let binary = LanguageServerBinary { + path: server_path.clone(), + env: None, + arguments: vec!["server".into()], }; - if let (Some(actual_digest), Some(expected_digest)) = - (&metadata.digest, &expected_digest) - { - if actual_digest == expected_digest { - if validity_check().await.is_ok() { - return Ok(binary); + + let metadata_path = destination_path.with_extension("metadata"); + let metadata = GithubBinaryMetadata::read_from_file(&metadata_path) + .await + .ok(); + if let Some(metadata) = metadata { + let validity_check = async || { + delegate + .try_exec(LanguageServerBinary { + path: server_path.clone(), + arguments: vec!["--version".into()], + env: None, + }) + .await + .inspect_err(|err| { + log::warn!( + "Unable to run {server_path:?} asset, redownloading: {err:#}", + ) + }) + }; + if let (Some(actual_digest), Some(expected_digest)) = + (&metadata.digest, &expected_digest) + { + if actual_digest == expected_digest { + if validity_check().await.is_ok() { + return Ok(binary); + } + } else { + log::info!( + "SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}" + ); } - } else { - log::info!( - "SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}" - ); + } else if validity_check().await.is_ok() { + return Ok(binary); } - } else if validity_check().await.is_ok() { - return Ok(binary); } - } - download_server_binary( - &*delegate.http_client(), - &url, - expected_digest.as_deref(), - &destination_path, - Self::GITHUB_ASSET_KIND, - ) - .await?; - make_file_executable(&server_path).await?; - remove_matching(&container_dir, |path| path != destination_path).await; - GithubBinaryMetadata::write_to_file( - &GithubBinaryMetadata { - metadata_version: 1, - digest: expected_digest, - }, - &metadata_path, - ) - .await?; + download_server_binary( + &*delegate.http_client(), + &url, + expected_digest.as_deref(), + &destination_path, + Self::GITHUB_ASSET_KIND, + ) + .await?; + make_file_executable(&server_path).await?; + remove_matching(&container_dir, |path| path != destination_path).await; + GithubBinaryMetadata::write_to_file( + &GithubBinaryMetadata { + metadata_version: 1, + digest: expected_digest, + }, + &metadata_path, + ) + .await?; - Ok(LanguageServerBinary { - path: server_path, - env: None, - arguments: vec!["server".into()], - }) + Ok(LanguageServerBinary { + path: server_path, + env: None, + arguments: vec!["server".into()], + }) + } } async fn cached_server_binary( diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index 57d86ea91f342a..de219d30928eed 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -19,6 +19,7 @@ use smallvec::SmallVec; use smol::fs::{self}; use std::cmp::Reverse; use std::fmt::Display; +use std::future::Future; use std::ops::Range; use std::{ borrow::Cow, @@ -729,87 +730,93 @@ impl LspInstaller for RustLspAdapter { }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, version: GitHubLspBinaryVersion, container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let GitHubLspBinaryVersion { - name, - url, - digest: expected_digest, - } = version; - let destination_path = container_dir.join(format!("rust-analyzer-{name}")); - let server_path = match Self::GITHUB_ASSET_KIND { - AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => destination_path.clone(), // Tar and gzip extract in place. - AssetKind::Zip => destination_path.clone().join("rust-analyzer.exe"), // zip contains a .exe - }; + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); - let binary = LanguageServerBinary { - path: server_path.clone(), - env: None, - arguments: Default::default(), - }; + async move { + let GitHubLspBinaryVersion { + name, + url, + digest: expected_digest, + } = version; + let destination_path = container_dir.join(format!("rust-analyzer-{name}")); + let server_path = match Self::GITHUB_ASSET_KIND { + AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => destination_path.clone(), // Tar and gzip extract in place. + AssetKind::Zip => destination_path.clone().join("rust-analyzer.exe"), // zip contains a .exe + }; - let metadata_path = destination_path.with_extension("metadata"); - let metadata = GithubBinaryMetadata::read_from_file(&metadata_path) - .await - .ok(); - if let Some(metadata) = metadata { - let validity_check = async || { - delegate - .try_exec(LanguageServerBinary { - path: server_path.clone(), - arguments: vec!["--version".into()], - env: None, - }) - .await - .inspect_err(|err| { - log::warn!("Unable to run {server_path:?} asset, redownloading: {err:#}",) - }) + let binary = LanguageServerBinary { + path: server_path.clone(), + env: None, + arguments: Default::default(), }; - if let (Some(actual_digest), Some(expected_digest)) = - (&metadata.digest, &expected_digest) - { - if actual_digest == expected_digest { - if validity_check().await.is_ok() { - return Ok(binary); + + let metadata_path = destination_path.with_extension("metadata"); + let metadata = GithubBinaryMetadata::read_from_file(&metadata_path) + .await + .ok(); + if let Some(metadata) = metadata { + let validity_check = async || { + delegate + .try_exec(LanguageServerBinary { + path: server_path.clone(), + arguments: vec!["--version".into()], + env: None, + }) + .await + .inspect_err(|err| { + log::warn!( + "Unable to run {server_path:?} asset, redownloading: {err:#}", + ) + }) + }; + if let (Some(actual_digest), Some(expected_digest)) = + (&metadata.digest, &expected_digest) + { + if actual_digest == expected_digest { + if validity_check().await.is_ok() { + return Ok(binary); + } + } else { + log::info!( + "SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}" + ); } - } else { - log::info!( - "SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}" - ); + } else if validity_check().await.is_ok() { + return Ok(binary); } - } else if validity_check().await.is_ok() { - return Ok(binary); } - } - download_server_binary( - &*delegate.http_client(), - &url, - expected_digest.as_deref(), - &destination_path, - Self::GITHUB_ASSET_KIND, - ) - .await?; - make_file_executable(&server_path).await?; - remove_matching(&container_dir, |path| path != destination_path).await; - GithubBinaryMetadata::write_to_file( - &GithubBinaryMetadata { - metadata_version: 1, - digest: expected_digest, - }, - &metadata_path, - ) - .await?; + download_server_binary( + &*delegate.http_client(), + &url, + expected_digest.as_deref(), + &destination_path, + Self::GITHUB_ASSET_KIND, + ) + .await?; + make_file_executable(&server_path).await?; + remove_matching(&container_dir, |path| path != destination_path).await; + GithubBinaryMetadata::write_to_file( + &GithubBinaryMetadata { + metadata_version: 1, + digest: expected_digest, + }, + &metadata_path, + ) + .await?; - Ok(LanguageServerBinary { - path: server_path, - env: None, - arguments: Default::default(), - }) + Ok(LanguageServerBinary { + path: server_path, + env: None, + arguments: Default::default(), + }) + } } async fn cached_server_binary( diff --git a/crates/languages/src/tailwind.rs b/crates/languages/src/tailwind.rs index c78790b74c81c9..41fa248a935aea 100644 --- a/crates/languages/src/tailwind.rs +++ b/crates/languages/src/tailwind.rs @@ -10,6 +10,7 @@ use semver::Version; use serde_json::{Value, json}; use std::{ ffi::OsString, + future::Future, path::{Path, PathBuf}, sync::Arc, }; @@ -69,55 +70,63 @@ impl LspInstaller for TailwindLspAdapter { }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: Self::BinaryVersion, container_dir: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Result { - let server_path = container_dir.join(SERVER_PATH); - let latest_version = latest_version.to_string(); + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); - self.node - .npm_install_packages( + async move { + let server_path = container_dir.join(SERVER_PATH); + let latest_version = latest_version.to_string(); + + node.npm_install_packages( &container_dir, &[(Self::PACKAGE_NAME, latest_version.as_str())], ) .await?; - Ok(LanguageServerBinary { - path: self.node.binary_path().await?, - env: None, - arguments: server_binary_arguments(&server_path), - }) + Ok(LanguageServerBinary { + path: node.binary_path().await?, + env: None, + arguments: server_binary_arguments(&server_path), + }) + } } - async fn check_if_version_installed( + fn check_if_version_installed( &self, version: &Self::BinaryVersion, container_dir: &PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Option { - let server_path = container_dir.join(SERVER_PATH); - - let should_install_language_server = self - .node - .should_install_npm_package( - Self::PACKAGE_NAME, - &server_path, - container_dir, - VersionStrategy::Latest(version), - ) - .await; - - if should_install_language_server { - None - } else { - Some(LanguageServerBinary { - path: self.node.binary_path().await.ok()?, - env: None, - arguments: server_binary_arguments(&server_path), - }) + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); + let version = version.clone(); + let container_dir = container_dir.clone(); + + async move { + let server_path = container_dir.join(SERVER_PATH); + + let should_install_language_server = node + .should_install_npm_package( + Self::PACKAGE_NAME, + &server_path, + &container_dir, + VersionStrategy::Latest(&version), + ) + .await; + + if should_install_language_server { + None + } else { + Some(LanguageServerBinary { + path: node.binary_path().await.ok()?, + env: None, + arguments: server_binary_arguments(&server_path), + }) + } } } diff --git a/crates/languages/src/tailwindcss.rs b/crates/languages/src/tailwindcss.rs index aa310fac3f5747..dcc9e8bf4ef6d5 100644 --- a/crates/languages/src/tailwindcss.rs +++ b/crates/languages/src/tailwindcss.rs @@ -9,6 +9,7 @@ use semver::Version; use serde_json::json; use std::{ ffi::OsString, + future::Future, path::{Path, PathBuf}, sync::Arc, }; @@ -65,55 +66,63 @@ impl LspInstaller for TailwindCssLspAdapter { }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: Self::BinaryVersion, container_dir: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Result { - let server_path = container_dir.join(SERVER_PATH); - let latest_version = latest_version.to_string(); + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); - self.node - .npm_install_packages( + async move { + let server_path = container_dir.join(SERVER_PATH); + let latest_version = latest_version.to_string(); + + node.npm_install_packages( &container_dir, &[(Self::PACKAGE_NAME, latest_version.as_str())], ) .await?; - Ok(LanguageServerBinary { - path: self.node.binary_path().await?, - env: None, - arguments: server_binary_arguments(&server_path), - }) + Ok(LanguageServerBinary { + path: node.binary_path().await?, + env: None, + arguments: server_binary_arguments(&server_path), + }) + } } - async fn check_if_version_installed( + fn check_if_version_installed( &self, version: &Self::BinaryVersion, container_dir: &PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Option { - let server_path = container_dir.join(SERVER_PATH); - - let should_install_language_server = self - .node - .should_install_npm_package( - Self::PACKAGE_NAME, - &server_path, - container_dir, - VersionStrategy::Latest(version), - ) - .await; - - if should_install_language_server { - None - } else { - Some(LanguageServerBinary { - path: self.node.binary_path().await.ok()?, - env: None, - arguments: server_binary_arguments(&server_path), - }) + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); + let version = version.clone(); + let container_dir = container_dir.clone(); + + async move { + let server_path = container_dir.join(SERVER_PATH); + + let should_install_language_server = node + .should_install_npm_package( + Self::PACKAGE_NAME, + &server_path, + &container_dir, + VersionStrategy::Latest(&version), + ) + .await; + + if should_install_language_server { + None + } else { + Some(LanguageServerBinary { + path: node.binary_path().await.ok()?, + env: None, + arguments: server_binary_arguments(&server_path), + }) + } } } diff --git a/crates/languages/src/typescript.rs b/crates/languages/src/typescript.rs index a83e36270d2ca1..d6889d8cbb8ce6 100644 --- a/crates/languages/src/typescript.rs +++ b/crates/languages/src/typescript.rs @@ -18,6 +18,7 @@ use smol::lock::RwLock; use std::{ borrow::Cow, ffi::OsString, + future::Future, path::{Path, PathBuf}, sync::{Arc, LazyLock}, }; @@ -669,76 +670,80 @@ impl LspInstaller for TypeScriptLspAdapter { }) } - async fn check_if_version_installed( + fn check_if_version_installed( &self, version: &Self::BinaryVersion, container_dir: &PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Option { - let server_path = container_dir.join(Self::NEW_SERVER_PATH); + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); + let typescript_version = version.typescript_version.clone(); + let server_version = version.server_version.clone(); + let container_dir = container_dir.clone(); + + async move { + let server_path = container_dir.join(Self::NEW_SERVER_PATH); + + if node + .should_install_npm_package( + Self::PACKAGE_NAME, + &server_path, + &container_dir, + VersionStrategy::Latest(&typescript_version), + ) + .await + { + return None; + } - if self - .node - .should_install_npm_package( - Self::PACKAGE_NAME, - &server_path, - container_dir, - VersionStrategy::Latest(&version.typescript_version), - ) - .await - { - return None; - } + if node + .should_install_npm_package( + Self::SERVER_PACKAGE_NAME, + &server_path, + &container_dir, + VersionStrategy::Latest(&server_version), + ) + .await + { + return None; + } - if self - .node - .should_install_npm_package( - Self::SERVER_PACKAGE_NAME, - &server_path, - container_dir, - VersionStrategy::Latest(&version.server_version), - ) - .await - { - return None; + Some(LanguageServerBinary { + path: node.binary_path().await.ok()?, + env: None, + arguments: typescript_server_binary_arguments(&server_path), + }) } - - Some(LanguageServerBinary { - path: self.node.binary_path().await.ok()?, - env: None, - arguments: typescript_server_binary_arguments(&server_path), - }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: Self::BinaryVersion, container_dir: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Result { - let server_path = container_dir.join(Self::NEW_SERVER_PATH); + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); + + async move { + let server_path = container_dir.join(Self::NEW_SERVER_PATH); + let typescript_version = latest_version.typescript_version.to_string(); + let server_version = latest_version.server_version.to_string(); - self.node - .npm_install_packages( + node.npm_install_packages( &container_dir, &[ - ( - Self::PACKAGE_NAME, - &latest_version.typescript_version.to_string(), - ), - ( - Self::SERVER_PACKAGE_NAME, - &latest_version.server_version.to_string(), - ), + (Self::PACKAGE_NAME, typescript_version.as_str()), + (Self::SERVER_PACKAGE_NAME, server_version.as_str()), ], ) .await?; - Ok(LanguageServerBinary { - path: self.node.binary_path().await?, - env: None, - arguments: typescript_server_binary_arguments(&server_path), - }) + Ok(LanguageServerBinary { + path: node.binary_path().await?, + env: None, + arguments: typescript_server_binary_arguments(&server_path), + }) + } } async fn cached_server_binary( diff --git a/crates/languages/src/vtsls.rs b/crates/languages/src/vtsls.rs index 23434b81a98589..4bc4401ff3046e 100644 --- a/crates/languages/src/vtsls.rs +++ b/crates/languages/src/vtsls.rs @@ -15,6 +15,7 @@ use serde_json::json; use settings::update_settings_file; use std::{ ffi::OsString, + future::Future, path::{Path, PathBuf}, sync::{Arc, LazyLock}, }; @@ -123,54 +124,56 @@ impl LspInstaller for VtslsLspAdapter { }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: Self::BinaryVersion, container_dir: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Result { - let server_path = container_dir.join(Self::SERVER_PATH); + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); + + async move { + let server_path = container_dir.join(Self::SERVER_PATH); + + let typescript_version = latest_version.typescript_version.to_string(); + let server_version = latest_version.server_version.to_string(); + + let mut packages_to_install = Vec::new(); + + if node + .should_install_npm_package( + Self::PACKAGE_NAME, + &server_path, + &container_dir, + VersionStrategy::Latest(&latest_version.server_version), + ) + .await + { + packages_to_install.push((Self::PACKAGE_NAME, server_version.as_str())); + } - let typescript_version = latest_version.typescript_version.to_string(); - let server_version = latest_version.server_version.to_string(); + if node + .should_install_npm_package( + Self::TYPESCRIPT_PACKAGE_NAME, + &container_dir.join(Self::TYPESCRIPT_TSDK_PATH), + &container_dir, + VersionStrategy::Latest(&latest_version.typescript_version), + ) + .await + { + packages_to_install + .push((Self::TYPESCRIPT_PACKAGE_NAME, typescript_version.as_str())); + } - let mut packages_to_install = Vec::new(); + node.npm_install_packages(&container_dir, &packages_to_install) + .await?; - if self - .node - .should_install_npm_package( - Self::PACKAGE_NAME, - &server_path, - &container_dir, - VersionStrategy::Latest(&latest_version.server_version), - ) - .await - { - packages_to_install.push((Self::PACKAGE_NAME, server_version.as_str())); + Ok(LanguageServerBinary { + path: node.binary_path().await?, + env: None, + arguments: typescript_server_binary_arguments(&server_path), + }) } - - if self - .node - .should_install_npm_package( - Self::TYPESCRIPT_PACKAGE_NAME, - &container_dir.join(Self::TYPESCRIPT_TSDK_PATH), - &container_dir, - VersionStrategy::Latest(&latest_version.typescript_version), - ) - .await - { - packages_to_install.push((Self::TYPESCRIPT_PACKAGE_NAME, typescript_version.as_str())); - } - - self.node - .npm_install_packages(&container_dir, &packages_to_install) - .await?; - - Ok(LanguageServerBinary { - path: self.node.binary_path().await?, - env: None, - arguments: typescript_server_binary_arguments(&server_path), - }) } async fn cached_server_binary( diff --git a/crates/languages/src/yaml.rs b/crates/languages/src/yaml.rs index e8bad8eb2059b0..22781acf25a2cd 100644 --- a/crates/languages/src/yaml.rs +++ b/crates/languages/src/yaml.rs @@ -12,6 +12,7 @@ use serde_json::Value; use settings::{Settings, SettingsLocation}; use std::{ ffi::OsString, + future::Future, path::{Path, PathBuf}, sync::Arc, }; @@ -65,54 +66,63 @@ impl LspInstaller for YamlLspAdapter { }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: Self::BinaryVersion, container_dir: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Result { - let server_path = container_dir.join(SERVER_PATH); + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); - self.node - .npm_install_packages( + async move { + let server_path = container_dir.join(SERVER_PATH); + let latest_version = latest_version.to_string(); + + node.npm_install_packages( &container_dir, - &[(Self::PACKAGE_NAME, &latest_version.to_string())], + &[(Self::PACKAGE_NAME, latest_version.as_str())], ) .await?; - Ok(LanguageServerBinary { - path: self.node.binary_path().await?, - env: None, - arguments: server_binary_arguments(&server_path), - }) + Ok(LanguageServerBinary { + path: node.binary_path().await?, + env: None, + arguments: server_binary_arguments(&server_path), + }) + } } - async fn check_if_version_installed( + fn check_if_version_installed( &self, version: &Self::BinaryVersion, container_dir: &PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Option { - let server_path = container_dir.join(SERVER_PATH); - - let should_install_language_server = self - .node - .should_install_npm_package( - Self::PACKAGE_NAME, - &server_path, - container_dir, - VersionStrategy::Latest(version), - ) - .await; - - if should_install_language_server { - None - } else { - Some(LanguageServerBinary { - path: self.node.binary_path().await.ok()?, - env: None, - arguments: server_binary_arguments(&server_path), - }) + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); + let version = version.clone(); + let container_dir = container_dir.clone(); + + async move { + let server_path = container_dir.join(SERVER_PATH); + + let should_install_language_server = node + .should_install_npm_package( + Self::PACKAGE_NAME, + &server_path, + &container_dir, + VersionStrategy::Latest(&version), + ) + .await; + + if should_install_language_server { + None + } else { + Some(LanguageServerBinary { + path: node.binary_path().await.ok()?, + env: None, + arguments: server_binary_arguments(&server_path), + }) + } } } diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 85229cfdcdeb34..55bf8c66c2ac0c 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -14349,13 +14349,13 @@ impl LspInstaller for SshLspAdapter { anyhow::bail!("SshLspAdapter does not support fetch_latest_server_version") } - async fn fetch_server_binary( + fn fetch_server_binary( &self, _: (), _: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Result { - anyhow::bail!("SshLspAdapter does not support fetch_server_binary") + _: &Arc, + ) -> impl Send + Future> + use<> { + async { anyhow::bail!("SshLspAdapter does not support fetch_server_binary") } } }