From b083358680a69ec982bd3efdcbcd1c90e08d6b2b Mon Sep 17 00:00:00 2001 From: Xin Zhao Date: Wed, 1 Jul 2026 23:18:54 +0800 Subject: [PATCH 001/197] Suppress `pet` log spam when opening Python projects (#60204) # Objective Zed uses `pet` to auto-discover Python virtual environments. When opening Python projects, there is a large amount of log spam from `pet` output, which is not useful: ``` 2026-07-01T22:32:07+08:00 INFO [pet::find] find_and_report_envs; search_scope=None 2026-07-01T22:32:07+08:00 INFO [lsp] starting language server process. binary path: "/usr/bin/ty", working directory: "/home/xin/works/test/test_python", args: ["server"] 2026-07-01T22:32:07+08:00 INFO [pet::find] locators_phase; 2026-07-01T22:32:07+08:00 INFO [pet::find] path_search_phase; 2026-07-01T22:32:07+08:00 INFO [pet::find] locator_find; locator=PipEnv 2026-07-01T22:32:07+08:00 INFO [pet::find] workspace_search_phase; 2026-07-01T22:32:07+08:00 INFO [pet::find] locator_find; locator=PyEnv 2026-07-01T22:32:07+08:00 INFO [pet::find] locator_find; locator=Pixi 2026-07-01T22:32:07+08:00 INFO [pet::find] locator_find; locator=Conda 2026-07-01T22:32:07+08:00 INFO [pet::find] locator_find; locator=Uv 2026-07-01T22:32:07+08:00 INFO [pet::find] locator_find; locator=Poetry 2026-07-01T22:32:07+08:00 INFO [pet::find] global_virtualenvs_phase; 2026-07-01T22:32:07+08:00 INFO [pet::find] locator_find; locator=Venv 2026-07-01T22:32:07+08:00 INFO [pet::find] identify_python_executables_using_locators; executables=[] executable_count=0 2026-07-01T22:32:07+08:00 INFO [pet::find] locator_find; locator=VirtualEnv 2026-07-01T22:32:07+08:00 INFO [pet::find] find_python_environments_in_workspace_folder_recursive; workspace_folder="/home/xin/works/test/test_python" workspace=/home/xin/works/test/test_python 2026-07-01T22:32:07+08:00 INFO [pet::find] locator_find; locator=Homebrew 2026-07-01T22:32:07+08:00 INFO [lsp] starting language server process. binary path: "/usr/bin/ruff", working directory: "/home/xin/works/test/test_python", args: ["server"] 2026-07-01T22:32:07+08:00 INFO [pet::find] identify_python_executables_using_locators; executables=[] executable_count=0 2026-07-01T22:32:07+08:00 INFO [pet::find] identify_python_executables_using_locators; executables=[] executable_count=0 2026-07-01T22:32:07+08:00 INFO [pet::find] identify_python_executables_using_locators; executables=[] executable_count=0 2026-07-01T22:32:07+08:00 INFO [pet::find] locator_find; locator=LinuxGlobal 2026-07-01T22:32:07+08:00 INFO [pet::find] identify_python_executables_using_locators; executables=[] executable_count=0 2026-07-01T22:32:07+08:00 INFO [pet::find] identify_python_executables_using_locators; executables=[] executable_count=0 2026-07-01T22:32:07+08:00 INFO [pet::find] identify_python_executables_using_locators; executables=[] executable_count=0 2026-07-01T22:32:07+08:00 INFO [pet::find] locator_find; locator=VirtualEnvWrapper 2026-07-01T22:32:07+08:00 INFO [pet::find] identify_python_executables_using_locators; executables=[] executable_count=0 2026-07-01T22:32:07+08:00 INFO [pet::find] identify_python_executables_using_locators; executables=["/home/xin/works/test/test_python/.venv/bin/python"] executable_count=1 2026-07-01T22:32:07+08:00 INFO [pet::locators] identify_python_environment_using_locators; env=PythonEnv { executable: "/home/xin/works/test/test_python/.venv/bin/python", prefix: Some("/home/xin/works/test/test_python/.venv"), version: None, symlinks: None } executable=/home/xin/works/test/test_python/.venv/bin/python 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=PyEnv 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=Pixi 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=Conda 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=Uv 2026-07-01T22:32:07+08:00 INFO [pet::find] identify_python_executables_using_locators; executables=[] executable_count=0 2026-07-01T22:32:07+08:00 INFO [pet::find] identify_python_executables_using_locators; executables=[] executable_count=0 2026-07-01T22:32:07+08:00 INFO [pet::find] identify_python_executables_using_locators; executables=[] executable_count=0 2026-07-01T22:32:07+08:00 INFO [pet::find] identify_python_executables_using_locators; executables=["/usr/bin/python", "/usr/bin/python3", "/usr/bin/python3.14"] executable_count=3 2026-07-01T22:32:07+08:00 INFO [pet::locators] identify_python_environment_using_locators; env=PythonEnv { executable: "/usr/bin/python", prefix: None, version: None, symlinks: None } executable=/usr/bin/python 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=PyEnv 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=Pixi 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=Conda 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=Uv 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=Poetry 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=PipEnv 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=VirtualEnvWrapper 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=Venv 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=VirtualEnv 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=Homebrew 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=LinuxGlobal 2026-07-01T22:32:07+08:00 INFO [pet::locators] resolve_python_env; executable=/usr/bin/python 2026-07-01T22:32:07+08:00 INFO [pet::locators] identify_python_environment_using_locators; env=PythonEnv { executable: "/usr/bin/python3", prefix: None, version: None, symlinks: None } executable=/usr/bin/python3 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=PyEnv 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=Pixi 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=Conda 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=Uv 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=Poetry 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=PipEnv 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=VirtualEnvWrapper 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=Venv 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=VirtualEnv 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=Homebrew 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=LinuxGlobal 2026-07-01T22:32:07+08:00 INFO [pet::locators] resolve_python_env; executable=/usr/bin/python3 2026-07-01T22:32:07+08:00 INFO [pet::locators] identify_python_environment_using_locators; env=PythonEnv { executable: "/usr/bin/python3.14", prefix: None, version: None, symlinks: None } executable=/usr/bin/python3.14 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=PyEnv 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=Pixi 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=Conda 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=Uv 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=Poetry 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=PipEnv 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=VirtualEnvWrapper 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=Venv 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=VirtualEnv 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=Homebrew 2026-07-01T22:32:07+08:00 INFO [pet::locators] try_from_locator; locator=LinuxGlobal 2026-07-01T22:32:07+08:00 INFO [pet::locators] resolve_python_env; executable=/usr/bin/python3.14 ``` This PR is intended to remove this log spam. ## Solution Add `pet` to the default log filters. Now, `INFO` logs from `pet` are suppressed by default. ## Testing Tested locally, and verified that there is no longer any `pet` log spam in the Zed logs. ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - N/A or Added/Fixed/Improved ... --- crates/zlog/src/filter.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/zlog/src/filter.rs b/crates/zlog/src/filter.rs index 710ddf761eb..4f03a47f609 100644 --- a/crates/zlog/src/filter.rs +++ b/crates/zlog/src/filter.rs @@ -42,6 +42,7 @@ const DEFAULT_FILTERS: &[(&str, log::LevelFilter)] = &[ // usvg prints a lot of warnings on rendering an SVG with partial errors, which // can happen a lot with the SVG preview ("usvg::parser", log::LevelFilter::Error), + ("pet", log::LevelFilter::Warn), ]; pub fn init_env_filter(filter: env_config::EnvFilter) { From 35c3d272828328b7217efccc146dc5b7d53490ff Mon Sep 17 00:00:00 2001 From: "zed-zippy[bot]" <234243425+zed-zippy[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:24:09 +0000 Subject: [PATCH 002/197] Bump Zed to v1.11.0 (#60209) Release Notes: - N/A Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d3be7e7882d..c3ae43b5a9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22965,7 +22965,7 @@ dependencies = [ [[package]] name = "zed" -version = "1.10.0" +version = "1.11.0" dependencies = [ "acp_thread", "acp_tools", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 50099f28a4a..2b67736bf0e 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "1.10.0" +version = "1.11.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From 7e0f63412c60008f9dae7fcf65fc6ab6d7e0f957 Mon Sep 17 00:00:00 2001 From: mertkanakkoc <52167810+mertkanakkoc@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:00:54 +0300 Subject: [PATCH 003/197] terminal_view: Use backslash escaping for dropped file paths for mac (#57747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 - [ ] Performance impact has been considered and is acceptable When files are dragged into the terminal, paths were formatted using Rust's Debug format (`{path:?}`), which wraps them in double quotes. This caused issues with programs like Claude Code that parse bare paths from terminal input — quoted paths were treated as plain text instead of file references. This change replaces double-quote wrapping with backslash escaping for special shell characters on Unix, which is both valid shell syntax and compatible with path-parsing tools running in the terminal. On non-Unix platforms the previous behavior is preserved. Tested on macOS only. Unable to verify Windows behavior. Fixes #57471 Release Notes: - Fixed/ file drag-and-drop into terminal inserting double-quoted paths, which prevented tools like Claude Code from recognizing them as file references. --------- Co-authored-by: Conrad Irwin Co-authored-by: Smit Barmase --- Cargo.lock | 2 ++ crates/agent_ui/Cargo.toml | 1 + crates/agent_ui/src/agent_panel.rs | 2 +- crates/terminal_view/Cargo.toml | 3 ++- crates/terminal_view/src/terminal_view.rs | 4 ++-- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c3ae43b5a9c..2445366c126 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -535,6 +535,7 @@ dependencies = [ "serde_json", "serde_json_lenient", "settings", + "shlex", "streaming_diff", "task", "telemetry", @@ -18419,6 +18420,7 @@ dependencies = [ "serde_json", "settings", "shellexpand", + "shlex", "task", "terminal", "theme", diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 8b25804d724..822b91c3cd1 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -143,6 +143,7 @@ remote_server = { workspace = true, features = ["test-support"] } search = { workspace = true, features = ["test-support"] } semver.workspace = true +shlex.workspace = true reqwest_client.workspace = true tempfile.workspace = true vim.workspace = true diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 7fe2d2c0b9f..88d8a5fd86d 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -9038,7 +9038,7 @@ mod tests { let mut text = String::new(); for path in paths { text.push(' '); - text.push_str(&format!("{path:?}")); + text.push_str(&shlex::try_quote(path.to_str().unwrap()).unwrap()); } text.push(' '); text diff --git a/crates/terminal_view/Cargo.toml b/crates/terminal_view/Cargo.toml index 9da4c5ec6e1..5197dee517a 100644 --- a/crates/terminal_view/Cargo.toml +++ b/crates/terminal_view/Cargo.toml @@ -32,13 +32,14 @@ menu.workspace = true pretty_assertions.workspace = true project.workspace = true regex.workspace = true -task.workspace = true schemars.workspace = true +task.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true shellexpand.workspace = true +shlex.workspace = true terminal.workspace = true theme.workspace = true theme_settings.workspace = true diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 6511497623a..ea888d564cc 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -959,7 +959,7 @@ impl TerminalView { pub fn add_paths_to_terminal(&self, paths: &[PathBuf], window: &mut Window, cx: &mut App) { let mut text = paths .iter() - .map(|path| format!(" {path:?}")) + .filter_map(|path| Some(format!(" {}", shlex::try_quote(path.to_str()?).ok()?))) .collect::(); text.push(' '); window.focus(&self.focus_handle(cx), cx); @@ -2176,7 +2176,7 @@ mod tests { let mut text = String::new(); for path in paths { text.push(' '); - text.push_str(&format!("{path:?}")); + text.push_str(&shlex::try_quote(path.to_str().unwrap()).unwrap()); } text.push(' '); text From 550ddc9405943cfd69f34646f7af0179a5b0be41 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:55:07 -0300 Subject: [PATCH 004/197] agent_ui: Replace thread controls with slash commands (#59974) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes AI-432 The turn-end action buttons in the agent panel (scroll to top, scroll to most recent prompt, open as markdown, thumbs up/down, share/sync) are now exposed as local slash commands in the message editor instead of buttons rendered at the end of each turn. The actions themselves are unchanged — this is just a different way to access them. Release Notes: - Changed some of the agent panel's turn-end action buttons into slash commands in the message editor. --------- Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- assets/icons/folder_share.svg | 4 +- assets/icons/folder_shared.svg | 4 +- assets/icons/user_arrow_up.svg | 6 + crates/agent_ui/src/agent_panel.rs | 61 +++- crates/agent_ui/src/completion_provider.rs | 120 +++++++- .../src/conversation_view/thread_view.rs | 261 ++++++++++++------ crates/agent_ui/src/message_editor.rs | 145 +++++++++- crates/icons/src/icons.rs | 1 + 8 files changed, 489 insertions(+), 113 deletions(-) create mode 100644 assets/icons/user_arrow_up.svg diff --git a/assets/icons/folder_share.svg b/assets/icons/folder_share.svg index 36db1414b8c..898af2397c7 100644 --- a/assets/icons/folder_share.svg +++ b/assets/icons/folder_share.svg @@ -1,5 +1,5 @@ - - + + diff --git a/assets/icons/folder_shared.svg b/assets/icons/folder_shared.svg index 785b3aa56d7..897bef603a1 100644 --- a/assets/icons/folder_shared.svg +++ b/assets/icons/folder_shared.svg @@ -1,6 +1,6 @@ - - + + diff --git a/assets/icons/user_arrow_up.svg b/assets/icons/user_arrow_up.svg new file mode 100644 index 00000000000..8320108fa79 --- /dev/null +++ b/assets/icons/user_arrow_up.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 88d8a5fd86d..f22037c239a 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -5499,6 +5499,10 @@ impl AgentPanel { .is_some_and(|thread| !thread.read(cx).is_generating_title()) }); + let has_thread_messages = conversation_view.as_ref().is_some_and(|conversation_view| { + conversation_view.read(cx).has_user_submitted_prompt(cx) + }); + let has_auth_methods = match &self.base_view { BaseView::AgentThread { conversation_view } => { conversation_view.read(cx).has_auth_methods() @@ -5534,15 +5538,15 @@ impl AgentPanel { .with_handle(self.agent_panel_menu_handle.clone()) .menu({ move |window, cx| { - Some(ContextMenu::build(window, cx, |mut menu, _window, _| { + Some(ContextMenu::build(window, cx, |mut menu, _window, cx| { menu = menu.context(menu_action_context.clone()); - if can_regenerate_thread_title { + if has_thread_messages { menu = menu.header("Current Thread"); if let Some(conversation_view) = conversation_view.as_ref() { - menu = menu - .entry("Regenerate Thread Title", None, { + if can_regenerate_thread_title { + menu = menu.entry("Regenerate Thread Title", None, { let conversation_view = conversation_view.clone(); let workspace = workspace.clone(); move |_, cx| { @@ -5552,8 +5556,29 @@ impl AgentPanel { cx, ); } - }) - .separator(); + }); + } + + let root_thread_view = + conversation_view.read(cx).root_thread_view(); + if let Some(thread_view) = root_thread_view { + let workspace = workspace.clone(); + menu = menu.entry("Open Thread as Markdown", None, { + move |window, cx| { + if let Some(workspace) = workspace.upgrade() { + thread_view.update(cx, |thread_view, cx| { + thread_view + .open_thread_as_markdown( + workspace, window, cx, + ) + .detach_and_log_err(cx); + }); + } + } + }); + } + + menu = menu.separator(); } } @@ -5631,11 +5656,29 @@ impl AgentPanel { }, ); } - - menu = menu.separator(); } - menu = menu.action("Profiles", Box::new(ManageProfiles::default())); + menu = menu + .separator() + .header("MCP Servers") + .action( + "Add Server…", + Box::new(zed_actions::OpenSettingsAt { + path: "context_servers".to_string(), + target: None, + }), + ) + .action( + "Install New Servers…", + Box::new(zed_actions::Extensions { + category_filter: Some( + zed_actions::ExtensionCategoryFilter::ContextServers, + ), + id: None, + }), + ) + .separator() + .action("Profiles", Box::new(ManageProfiles::default())); } menu = menu diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index d25eae39064..ccc95c2fc4e 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -190,6 +190,51 @@ impl PromptContextAction { } } +/// A slash command that runs a local UI action against the conversation +/// (sending feedback) instead of being sent to the agent as part of a prompt. +/// Each variant maps to a method on `ThreadView`; the completion provider only +/// surfaces them and emits an event, while `ThreadView` performs the actual +/// work (see `handle_message_editor_event`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PromptLocalCommand { + ThumbsUp, + ThumbsDown, +} + +impl PromptLocalCommand { + pub fn keyword(&self) -> &'static str { + match self { + Self::ThumbsUp => "helpful", + Self::ThumbsDown => "not-helpful", + } + } + + pub fn label(&self) -> &'static str { + match self { + Self::ThumbsUp => "Positive Feedback", + Self::ThumbsDown => "Negative Feedback", + } + } + + pub fn description(&self) -> &'static str { + match self { + Self::ThumbsUp => { + "Rate this response as helpful. Sends the current conversation to the Zed team." + } + Self::ThumbsDown => { + "Rate this response as not helpful. Sends the current conversation to the Zed team." + } + } + } + + pub fn icon(&self) -> IconName { + match self { + Self::ThumbsUp => IconName::ThumbsUp, + Self::ThumbsDown => IconName::ThumbsDown, + } + } +} + impl TryFrom<&str> for PromptContextType { type Error = String; @@ -366,13 +411,15 @@ impl AvailableCommand { enum SlashCompletionCandidate { Skill(AvailableSkill), Command(AvailableCommand), + LocalCommand(PromptLocalCommand), } impl SlashCompletionCandidate { - fn name(&self) -> &Arc { + fn name(&self) -> &str { match self { Self::Skill(skill) => &skill.name, Self::Command(command) => &command.name, + Self::LocalCommand(command) => command.keyword(), } } } @@ -385,6 +432,7 @@ fn slash_completion_group_key(candidate: &SlashCompletionCandidate) -> u32 { match candidate { SlashCompletionCandidate::Skill(_) => 0, SlashCompletionCandidate::Command(command) => 1 + command.category_order() as u32, + SlashCompletionCandidate::LocalCommand(_) => 4, } } @@ -411,6 +459,13 @@ pub trait PromptCompletionProviderDelegate: Send + Sync + 'static { fn available_skills(&self, _cx: &App) -> Vec { Vec::new() } + + fn available_local_commands(&self, _cx: &App) -> Vec { + Vec::new() + } + + fn run_local_command(&self, _command: PromptLocalCommand, _cx: &mut App) {} + fn confirm_command(&self, cx: &mut App); /// Called once each time the user opens slash-command autocomplete @@ -959,15 +1014,21 @@ impl PromptCompletionProvider { let mut candidates = self .source - .available_skills(cx) + .available_commands(cx) .into_iter() - .map(SlashCompletionCandidate::Skill) + .map(SlashCompletionCandidate::Command) .collect::>(); candidates.extend( self.source - .available_commands(cx) + .available_skills(cx) .into_iter() - .map(SlashCompletionCandidate::Command), + .map(SlashCompletionCandidate::Skill), + ); + candidates.extend( + self.source + .available_local_commands(cx) + .into_iter() + .map(SlashCompletionCandidate::LocalCommand), ); if candidates.is_empty() { return Task::ready(Vec::new()); @@ -1429,7 +1490,10 @@ impl CompletionProvider for PromptCompletio Some((new_text, icon_path, icon_color, confirm)), ) } - SlashCompletionCandidate::Command(_) => (candidate, None), + SlashCompletionCandidate::Command(_) + | SlashCompletionCandidate::LocalCommand(_) => { + (candidate, None) + } }) .collect::)>>() }) @@ -1542,6 +1606,50 @@ impl CompletionProvider for PromptCompletio group, } } + SlashCompletionCandidate::LocalCommand(command) => { + let group = show_section_headers.then(|| CompletionGroup { + key: "local-commands".into(), + label: Some("Actions".into()), + }); + + Completion { + replace_range: source_range.clone(), + // Local commands aren't part of the prompt; + // confirming one clears the typed text + // rather than leaving `/keyword` behind. + new_text: String::new(), + label: CodeLabel::plain(command.label().to_string(), None), + documentation: Some( + CompletionDocumentation::MultiLinePlainText( + command.description().into(), + ), + ), + source: project::CompletionSource::Custom, + icon_path: Some(command.icon().path().into()), + icon_color: None, + match_start: None, + snippet_deduplication_key: None, + insert_text_mode: None, + confirm: Some(Arc::new({ + let source = source.clone(); + move |intent, _window, cx| { + cx.defer({ + let source = source.clone(); + move |cx| match intent { + CompletionIntent::Complete + | CompletionIntent::CompleteWithInsert + | CompletionIntent::CompleteWithReplace => { + source.run_local_command(command, cx); + } + CompletionIntent::Compose => {} + } + }); + false + } + })), + group, + } + } }) .collect(); diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index fc0f5b96a58..858fc5e840e 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -23,7 +23,7 @@ use editor::actions::OpenExcerpts; use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _}; use sandbox::{SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy}; -use crate::completion_provider::AvailableSkill; +use crate::completion_provider::{AvailableSkill, PromptLocalCommand}; use crate::message_editor::SharedSessionCapabilities; use crate::ui::{SandboxGroup, SandboxRow, SandboxSection, SandboxStatusTooltip}; @@ -36,6 +36,7 @@ use language_model::{ FastModeConfirmation, LanguageModel, LanguageModelEffortLevel, LanguageModelId, LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, Speed, }; +use notifications::status_toast::StatusToast; use settings::{update_settings_file, update_settings_file_with_completion}; use ui::{ ButtonLike, CalloutBorderPosition, SpinnerLabel, SpinnerVariant, SplitButton, SplitButtonStyle, @@ -1109,11 +1110,48 @@ impl ThreadView { } MessageEditorEvent::LostFocus => {} MessageEditorEvent::SlashAutocompleteOpened => {} + MessageEditorEvent::LocalCommandInvoked(command) => { + self.run_local_command(*command, window, cx); + } MessageEditorEvent::InputAttempted { .. } => {} MessageEditorEvent::Edited => {} } } + fn run_local_command( + &mut self, + command: PromptLocalCommand, + window: &mut Window, + cx: &mut Context, + ) { + match command { + PromptLocalCommand::ThumbsUp => { + self.handle_feedback_click(ThreadFeedback::Positive, window, cx); + self.show_local_command_toast("Thanks for your feedback!", cx); + } + PromptLocalCommand::ThumbsDown => { + self.handle_feedback_click(ThreadFeedback::Negative, window, cx); + } + } + } + + fn show_local_command_toast(&self, message: impl Into, cx: &mut Context) { + // Shown after positive feedback, replacing the inline button state. + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + workspace.update(cx, |workspace, cx| { + let toast = StatusToast::new(message, cx, |this, _cx| { + this.icon( + Icon::new(IconName::Check) + .size(IconSize::Small) + .color(Color::Success), + ) + }); + workspace.toggle_status_toast(toast, cx); + }); + } + pub(crate) fn as_native_connection( &self, cx: &App, @@ -1250,6 +1288,7 @@ impl ThreadView { } ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::SlashAutocompleteOpened) => { } + ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::LocalCommandInvoked(_)) => {} ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Edited) => {} ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::InputAttempted { .. }) => {} ViewEvent::OpenDiffLocation { @@ -6580,42 +6619,50 @@ impl ThreadView { cx: &Context, ) -> impl IntoElement { let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); + if is_generating { return Empty.into_any_element(); } - let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Open Thread as Markdown")) - .on_click(cx.listener(move |this, _, window, cx| { - if let Some(workspace) = this.workspace.upgrade() { - this.open_thread_as_markdown(workspace, window, cx) - .detach_and_log_err(cx); - } - })); + let last_response_index = thread + .read(cx) + .entries() + .iter() + .rposition(|entry| matches!(entry, AgentThreadEntry::AssistantMessage(_))); + + let copy_response_button = last_response_index.map(|response_index| { + IconButton::new("copy_agent_response", IconName::Copy) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Copy This Agent Response")) + .on_click(cx.listener(move |this, _, _, cx| { + let entries = this.thread.read(cx).entries(); + if let Some(text) = Self::get_agent_message_content(entries, response_index, cx) + { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } + })) + }); let scroll_to_recent_user_prompt = - IconButton::new("scroll_to_recent_user_prompt", IconName::ForwardArrow) - .shape(ui::IconButtonShape::Square) + IconButton::new("scroll_to_recent_user_prompt", IconName::UserArrowUp) .icon_size(IconSize::Small) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Scroll To Most Recent User Prompt")) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Scroll to Most Recent User Message")) .on_click(cx.listener(move |this, _, _, cx| { this.scroll_to_most_recent_user_prompt(cx); })); let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp) - .shape(ui::IconButtonShape::Square) .icon_size(IconSize::Small) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Scroll To Top")) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Scroll to Top")) .on_click(cx.listener(move |this, _, _, cx| { this.scroll_to_top(cx); })); let show_stats = AgentSettings::get_global(cx).show_turn_stats; + let last_turn_clock = show_stats .then(|| { self.turn_fields @@ -6643,29 +6690,98 @@ impl ThreadView { }) .flatten(); - let mut container = h_flex() + let feedback_buttons = (self.is_subagent() && self.is_thread_feedback_enabled(cx)).then( + || { + let feedback = self.thread_feedback.feedback; + let tooltip_meta = + "Rating the thread sends all of your current conversation to the Zed team."; + + h_flex() + .child( + IconButton::new("feedback-thumbs-up", IconName::ThumbsUp) + .icon_size(IconSize::Small) + .icon_color(match feedback { + Some(ThreadFeedback::Positive) => Color::Accent, + _ => Color::Muted, + }) + .tooltip(move |window, cx| match feedback { + Some(ThreadFeedback::Positive) => { + Tooltip::text("Thanks for your feedback!")(window, cx) + } + _ => Tooltip::with_meta( + "Helpful Response", + None, + tooltip_meta, + cx, + ), + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.handle_feedback_click(ThreadFeedback::Positive, window, cx); + })), + ) + .child( + IconButton::new("feedback-thumbs-down", IconName::ThumbsDown) + .icon_size(IconSize::Small) + .icon_color(match feedback { + Some(ThreadFeedback::Negative) => Color::Accent, + _ => Color::Muted, + }) + .tooltip(move |window, cx| match feedback { + Some(ThreadFeedback::Negative) => Tooltip::text( + "We appreciate your feedback and will use it to improve in the future.", + )( + window, cx + ), + _ => Tooltip::with_meta( + "Not Helpful Response", + None, + tooltip_meta, + cx, + ), + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.handle_feedback_click(ThreadFeedback::Negative, window, cx); + })), + ) + }, + ); + + h_flex() .w_full() - .py_2() - .px_5() - .gap_px() - .opacity(0.6) - .hover(|s| s.opacity(1.)) + .py_1p5() + .px_4() .justify_end() + .opacity(0.4) + .hover(|s| s.opacity(1.)) .when( last_turn_tokens_label.is_some() || last_turn_clock.is_some(), |this| { this.child( h_flex() - .gap_1() .px_1() - .when_some(last_turn_tokens_label, |this, label| this.child(label)) + .gap_1() + .when_some(last_turn_tokens_label, |this, label| { + this.child(label).child( + Label::new("•") + .size(LabelSize::Small) + .color(Color::Muted) + .alpha(0.5), + ) + }) .when_some(last_turn_clock, |this, label| this.child(label)), ) }, - ); + ) + .when_some(feedback_buttons, |this, buttons| this.child(buttons)) + .when_some(copy_response_button, |this, button| this.child(button)) + .child(scroll_to_recent_user_prompt) + .child(scroll_to_top) + .into_any_element() + } - let enable_thread_feedback = util::maybe!({ - let project = thread.read(cx).project().read(cx); + fn is_thread_feedback_enabled(&self, cx: &App) -> bool { + util::maybe!({ + let project = self.thread.read(cx).project().read(cx); let user_store = project.user_store(); if let Some(configuration) = user_store.read(cx).current_organization_configuration() { if !configuration.is_agent_thread_feedback_enabled { @@ -6675,72 +6791,28 @@ impl ThreadView { AgentSettings::get_global(cx).enable_feedback && self.thread.read(cx).connection().telemetry().is_some() - }); + }) + } - if enable_thread_feedback { - let feedback = self.thread_feedback.feedback; + // The local slash commands the message editor should currently expose. + // Kept in sync with the availability of the corresponding actions via + // `sync_local_commands`. + fn available_local_commands(&self, cx: &App) -> Vec { + let mut commands = Vec::new(); - let tooltip_meta = || { - SharedString::new( - "Rating the thread sends all of your current conversation to the Zed team.", - ) - }; - - container = container - .child( - IconButton::new("feedback-thumbs-up", IconName::ThumbsUp) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(match feedback { - Some(ThreadFeedback::Positive) => Color::Accent, - _ => Color::Ignored, - }) - .tooltip(move |window, cx| match feedback { - Some(ThreadFeedback::Positive) => { - Tooltip::text("Thanks for your feedback!")(window, cx) - } - _ => { - Tooltip::with_meta("Helpful Response", None, tooltip_meta(), cx) - } - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.handle_feedback_click(ThreadFeedback::Positive, window, cx); - })), - ) - .child( - IconButton::new("feedback-thumbs-down", IconName::ThumbsDown) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(match feedback { - Some(ThreadFeedback::Negative) => Color::Accent, - _ => Color::Ignored, - }) - .tooltip(move |window, cx| match feedback { - Some(ThreadFeedback::Negative) => { - Tooltip::text( - "We appreciate your feedback and will use it to improve in the future.", - )(window, cx) - } - _ => { - Tooltip::with_meta( - "Not Helpful Response", - None, - tooltip_meta(), - cx, - ) - } - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.handle_feedback_click(ThreadFeedback::Negative, window, cx); - })), - ); + if self.is_thread_feedback_enabled(cx) { + commands.push(PromptLocalCommand::ThumbsUp); + commands.push(PromptLocalCommand::ThumbsDown); } - container - .child(open_as_markdown) - .child(scroll_to_recent_user_prompt) - .child(scroll_to_top) - .into_any_element() + commands + } + + // Pushes the current set of available local commands to the message + // editor so they appear in its slash-command popup. + pub(crate) fn sync_local_commands(&self, cx: &App) { + let commands = self.available_local_commands(cx); + self.message_editor.read(cx).set_local_commands(commands); } fn render_request_elicitations(&self, cx: &Context) -> Vec { @@ -11523,6 +11595,11 @@ impl ThreadView { impl Render for ThreadView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // Keep the message editor's local slash commands in sync with the + // current availability of feedback/sharing, which can change between + // renders (settings, connection state, feature flags). + self.sync_local_commands(cx); + let has_messages = self.list_state.item_count() > 0; let list_state = self.list_state.clone(); diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index f262e44f506..f9878d6c5ad 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -5,7 +5,7 @@ use crate::{ completion_provider::{ AgentContextSelection, AvailableCommand, AvailableSkill, PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextAction, PromptContextType, - SlashCommandCompletion, + PromptLocalCommand, SlashCommandCompletion, }, mention_set::{Mention, MentionImage, MentionSet, insert_crease_for_mention}, }; @@ -140,10 +140,13 @@ impl SessionCapabilities { pub type SharedSessionCapabilities = Arc>; +pub type SharedLocalCommands = Arc>>; + struct MessageEditorCompletionDelegate { session_capabilities: SharedSessionCapabilities, has_thread_store: bool, message_editor: WeakEntity, + local_commands: SharedLocalCommands, } impl PromptCompletionProviderDelegate for MessageEditorCompletionDelegate { @@ -165,6 +168,18 @@ impl PromptCompletionProviderDelegate for MessageEditorCompletionDelegate { self.session_capabilities.read().completion_skills() } + fn available_local_commands(&self, _cx: &App) -> Vec { + self.local_commands.read().clone() + } + + fn run_local_command(&self, command: PromptLocalCommand, cx: &mut App) { + self.message_editor + .update(cx, |_this, cx| { + cx.emit(MessageEditorEvent::LocalCommandInvoked(command)); + }) + .ok(); + } + fn slash_autocomplete_invoked(&self, cx: &mut App) { // This may be called synchronously from inside a `MessageEditor` // update (e.g. when pasting a slash command triggers completions), @@ -189,6 +204,7 @@ pub struct MessageEditor { editor: Entity, workspace: WeakEntity, session_capabilities: SharedSessionCapabilities, + local_commands: SharedLocalCommands, agent_id: AgentId, thread_store: Option>, _subscriptions: Vec, @@ -213,6 +229,11 @@ pub enum MessageEditorEvent { /// editor. Used by `ThreadView` to fire the global-skills scan /// trigger; see `NativeAgent::ensure_skills_scan_started`. SlashAutocompleteOpened, + /// Emitted when the user confirms a local slash command (scrolling, + /// exporting, feedback) in this editor's completion popup. `ThreadView` + /// handles it by running the corresponding action; see + /// `handle_message_editor_event`. + LocalCommandInvoked(PromptLocalCommand), InputAttempted { attempt: InputAttempt, cursor_offset: usize, @@ -484,11 +505,13 @@ impl MessageEditor { editor }); let mention_set = cx.new(|_cx| MentionSet::new(project, thread_store.clone())); + let local_commands: SharedLocalCommands = Arc::new(RwLock::new(Vec::new())); let completion_provider = Rc::new(PromptCompletionProvider::new( MessageEditorCompletionDelegate { session_capabilities: session_capabilities.clone(), has_thread_store: thread_store.is_some(), message_editor: cx.weak_entity(), + local_commands: local_commands.clone(), }, editor.downgrade(), mention_set.clone(), @@ -583,6 +606,7 @@ impl MessageEditor { mention_set, workspace, session_capabilities, + local_commands, agent_id, thread_store, _subscriptions: subscriptions, @@ -590,6 +614,10 @@ impl MessageEditor { } } + pub fn set_local_commands(&self, commands: Vec) { + *self.local_commands.write() = commands; + } + pub fn set_session_capabilities( &mut self, session_capabilities: SharedSessionCapabilities, @@ -2211,8 +2239,9 @@ fn find_matching_bracket(text: &str, open: char, close: char) -> Option { #[cfg(test)] mod tests { - use std::{ops::Range, path::Path, path::PathBuf, sync::Arc}; + use std::{ops::Range, path::Path, path::PathBuf, rc::Rc, sync::Arc}; + use super::PromptLocalCommand; use acp_thread::MentionUri; use agent::{ThreadStore, outline}; use agent_client_protocol::schema::v1 as acp; @@ -2909,6 +2938,118 @@ mod tests { }); } + /// Local commands set via `set_local_commands` must surface in the + /// slash-command popup, and confirming one must emit + /// `MessageEditorEvent::LocalCommandInvoked` (so `ThreadView` can run the + /// corresponding action) without leaving the `/keyword` in the editor. + #[gpui::test] + async fn test_local_commands_complete_and_emit_event(cx: &mut TestAppContext) { + init_test(cx); + + let app_state = cx.update(AppState::test); + + cx.update(|cx| { + editor::init(cx); + workspace::init(app_state.clone(), cx); + }); + + let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + + let mut cx = VisualTestContext::from_window(window.into(), cx); + + let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::from_acp_commands( + acp::PromptCapabilities::default(), + Vec::new(), + ))); + + let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { + let workspace_handle = cx.weak_entity(); + let message_editor = cx.new(|cx| { + MessageEditor::new( + workspace_handle, + project.downgrade(), + None, + session_capabilities.clone(), + "Test Agent".into(), + "Test", + EditorMode::AutoHeight { + max_lines: None, + min_lines: 1, + }, + window, + cx, + ) + }); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item( + Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))), + true, + true, + None, + window, + cx, + ); + }); + message_editor.read(cx).focus_handle(cx).focus(window, cx); + let editor = message_editor.read(cx).editor().clone(); + (message_editor, editor) + }); + + message_editor.read_with(&cx, |message_editor, _| { + message_editor.set_local_commands(vec![ + PromptLocalCommand::ThumbsUp, + PromptLocalCommand::ThumbsDown, + ]); + }); + + let invoked = Rc::new(std::cell::RefCell::new(Vec::new())); + let _subscription = cx.update(|_, cx| { + cx.subscribe(&message_editor, { + let invoked = invoked.clone(); + move |_editor, event, _cx| { + if let MessageEditorEvent::LocalCommandInvoked(command) = event { + invoked.borrow_mut().push(*command); + } + } + }) + }); + + // `/helpful` would fuzzy-match both commands ("helpful" is a + // subsequence of "not-helpful"), so drive the unambiguous keyword. + cx.simulate_input("/not-helpful"); + cx.run_until_parked(); + + editor.read_with(&cx, |editor, _| { + assert!(editor.has_visible_completions_menu()); + assert_eq!( + current_completion_labels(editor), + &[PromptLocalCommand::ThumbsDown.label().to_string()], + ); + }); + + editor.update_in(&mut cx, |editor, window, cx| { + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + cx.run_until_parked(); + + editor.read_with(&cx, |editor, cx| { + // The `/keyword` text is removed when a local command is confirmed. + assert_eq!(editor.text(cx), ""); + assert!(!editor.has_visible_completions_menu()); + }); + + assert_eq!( + invoked.borrow().as_slice(), + &[PromptLocalCommand::ThumbsDown], + ); + } + /// Opening slash-command autocomplete must emit /// [`MessageEditorEvent::SlashAutocompleteOpened`]. `ThreadView` /// subscribes to that event to fire the global-skills scan trigger diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 56182da2133..3e22896e325 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -281,6 +281,7 @@ pub enum IconName { TriangleRight, Undo, Unpin, + UserArrowUp, UserCheck, UserGroup, UserRoundPen, From bcd54d0adf13bf261c2d148c80941c51bd82ffa7 Mon Sep 17 00:00:00 2001 From: Kai Kozlov <37962720+kaikozlov@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:36:23 -0500 Subject: [PATCH 005/197] editor: Add intelligent bracket colorization (#51580) Closes #50210 Bracket colorization currently runs theme accents through `ensure_minimum_contrast`, which can significantly alter authored palette colors in order to satisfy a single foreground-vs-background target. In practice, that makes rainbow brackets drift away from the theme colors they are supposed to reflect. This PR keeps bracket colorization as a single automatic behavior, but changes how the palette is derived: - preserve authored accent colors when they already work - adjust only the specific accents that are too weak against the editor background - only reorder the palette when adjacent bracket levels are still too similar after that The implementation is staged because bracket colorization has to handle two separate problems: - **Bracket-vs-Background Readability**: is an individual bracket visible against the editor background? - **Adjacent Bracket Separation**: can you still distinguish one nesting level from the next? Those two constraints are also why the implementation cannot simply defer to whatever accent colors the theme provides. Some themes, including the bundled One and Ayu, do not define explicit accents at all, so bracket colorization falls back to Zed's shared default accent palette. That palette was not designed with any particular theme's background in mind, which makes the background contrast check especially important for those cases. That is why the previous `ensure_minimum_contrast(..., 55.0)` path is not enough on its own. It addresses foreground-vs-background readability, but if lightness-only adjustment cannot satisfy that target it escalates to saturation reduction and then black/white fallback. This PR instead keeps the bracket-specific background fix bounded and lightness-only in OKLCH, so the resulting color stays much closer to the authored accent. ### How it works - **Background Contrast Correction**: accents that fall below the APCA floor against the editor background are adjusted by changing OKLCH lightness only, preserving hue and chroma - **Adjacency Correction**: if the resulting palette is still too weak between neighboring nesting levels, the palette is reordered conservatively to improve adjacent separation That ordering matters. Doing the reorder first can easily be undone by the background-fix step. ### Color measurement - background contrast uses APCA, via the existing `apca_contrast` path - adjacent bracket separation uses OKLab Euclidean distance APCA is the right tool for bracket-vs-background readability, but it is not designed to compare two foreground colors against each other. OKLab distance is a better fit for detecting when neighboring bracket levels are perceptually too close. ### Thresholds - background APCA floor: `30` on dark themes, `35` on light themes - adjacent OKLab target: `0.08` on dark themes, `0.10` on light themes - light-theme reorder tolerance band: reorder only triggers below `0.095`, avoiding threshold-artifact reordering on palettes that are visually fine but narrowly miss `0.10` ### Validation - `cargo test -p editor bracket_colorization` - manual comparison on bundled One, Ayu, and Gruvbox theme families Release Notes: - Improved bracket colorization by preserving theme accent colors when possible and applying targeted contrast fixes only when needed. --------- Co-authored-by: Kirill Bulatov --- crates/editor/src/bracket_colorization.rs | 535 ++++++++++++++++++---- crates/editor/src/editor.rs | 9 +- crates/theme/src/color_space.rs | 76 +++ crates/theme/src/theme.rs | 2 + 4 files changed, 530 insertions(+), 92 deletions(-) create mode 100644 crates/theme/src/color_space.rs diff --git a/crates/editor/src/bracket_colorization.rs b/crates/editor/src/bracket_colorization.rs index 8c8c3a36e9a..3b90d1e2bc0 100644 --- a/crates/editor/src/bracket_colorization.rs +++ b/crates/editor/src/bracket_colorization.rs @@ -2,15 +2,18 @@ //! Uses tree-sitter queries from brackets.scm to capture bracket pairs, //! and theme accents to colorize those. +use std::cmp::Ordering; use std::ops::Range; +use std::sync::Arc; use crate::{Editor, HighlightKey}; use collections::{HashMap, HashSet}; -use gpui::{AppContext as _, Context, HighlightStyle}; +use gpui::{AppContext as _, Context, HighlightStyle, Hsla}; use language::{BufferRow, BufferSnapshot, language_settings::LanguageSettings}; use multi_buffer::{Anchor, BufferOffset, ExcerptRange, MultiBufferSnapshot}; use text::OffsetRangeExt as _; -use ui::{ActiveTheme, utils::ensure_minimum_contrast}; +use theme::{Appearance, Oklab, Oklch, hsla_to_oklab, hsla_to_oklch, oklch_to_hsla}; +use ui::utils::apca_contrast; impl Editor { pub(crate) fn colorize_brackets(&mut self, invalidate: bool, cx: &mut Context) { @@ -22,7 +25,10 @@ impl Editor { self.bracket_fetched_tree_sitter_chunks.clear(); } - let accents_count = cx.theme().accents().0.len(); + let Some(accent_data) = self.accent_data.as_ref() else { + return; + }; + let accents = accent_data.colors.0.clone(); let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx); let visible_excerpts = self.visible_buffer_ranges(cx); @@ -52,7 +58,12 @@ impl Editor { }) .collect::, HashSet>>>(); + let accents_count = accents.len(); let bracket_matches_by_accent = cx.background_spawn(async move { + if accents_count == 0 { + return (HashMap::default(), fetched_tree_sitter_chunks); + } + let bracket_matches_by_accent: HashMap>> = excerpt_data.into_iter().fold( HashMap::default(), @@ -92,9 +103,6 @@ impl Editor { (bracket_matches_by_accent, fetched_tree_sitter_chunks) }); - let editor_background = cx.theme().colors().editor_background; - let accents = cx.theme().accents().clone(); - self.colorize_brackets_task = cx.spawn(async move |editor, cx| { if invalidate { editor @@ -115,11 +123,11 @@ impl Editor { .bracket_fetched_tree_sitter_chunks .extend(updated_chunks); for (accent_number, bracket_highlights) in bracket_matches_by_accent { - let bracket_color = accents.color_for_index(accent_number as u32); - let adjusted_color = - ensure_minimum_contrast(bracket_color, editor_background, 55.0); + let Some(&bracket_color) = accents.get(accent_number) else { + continue; + }; let style = HighlightStyle { - color: Some(adjusted_color), + color: Some(bracket_color), ..HighlightStyle::default() }; @@ -137,6 +145,194 @@ impl Editor { } } +const BACKGROUND_APCA_LIGHT: f32 = 35.0; +const BACKGROUND_APCA_DARK: f32 = 30.0; +const ADJACENT_OKLAB_LIGHT: f32 = 0.10; +const ADJACENT_OKLAB_DARK: f32 = 0.08; +const ADJACENT_OKLAB_LIGHT_INTERVENTION: f32 = 0.095; +const ADJACENT_OKLAB_DARK_INTERVENTION: f32 = 0.08; +const LIGHTNESS_CLAMP_MIN: f32 = 0.18; +const LIGHTNESS_CLAMP_MAX: f32 = 0.92; + +pub(crate) fn bracket_colorization_accents( + accents: &[Hsla], + appearance: Appearance, + background: Hsla, +) -> Arc<[Hsla]> { + let (intervention_distance, comfortable_distance, min_background_contrast) = match appearance { + Appearance::Light => ( + ADJACENT_OKLAB_LIGHT_INTERVENTION, + ADJACENT_OKLAB_LIGHT, + BACKGROUND_APCA_LIGHT, + ), + Appearance::Dark => ( + ADJACENT_OKLAB_DARK_INTERVENTION, + ADJACENT_OKLAB_DARK, + BACKGROUND_APCA_DARK, + ), + }; + let background_adjusted = accents + .iter() + .copied() + .map(|accent| adjust_color_for_background(accent, background, min_background_contrast)) + .collect::>(); + let adjusted_min_adj = min_adjacent_oklab_distance(&background_adjusted, background); + + if accents.len() < 3 || adjusted_min_adj >= intervention_distance { + return Arc::from(background_adjusted); + } + + let reordered = maximize_adjacent_separation(&background_adjusted, background); + if min_adjacent_oklab_distance(&reordered, background) >= comfortable_distance { + Arc::from(reordered) + } else { + Arc::from(background_adjusted) + } +} + +fn maximize_adjacent_separation(accents: &[Hsla], background: Hsla) -> Vec { + let Some((&first, rest)) = accents.split_first() else { + return Vec::new(); + }; + let mut remaining = rest.to_vec(); + let mut order = Vec::with_capacity(accents.len()); + order.push(first); + let mut last = first; + + while !remaining.is_empty() { + let Some((position, &next)) = + remaining + .iter() + .enumerate() + .max_by(|&(_, &left), &(_, &right)| { + compare_candidates(background, last, first, left, right) + }) + else { + break; + }; + remaining.swap_remove(position); + order.push(next); + last = next; + } + + order +} + +fn compare_candidates( + background: Hsla, + last: Hsla, + first: Hsla, + left: Hsla, + right: Hsla, +) -> Ordering { + adjacent_distance(last, left, background) + .partial_cmp(&adjacent_distance(last, right, background)) + .unwrap_or(Ordering::Equal) + .then_with(|| { + adjacent_distance(first, left, background) + .partial_cmp(&adjacent_distance(first, right, background)) + .unwrap_or(Ordering::Equal) + }) +} + +fn min_adjacent_oklab_distance(accents: &[Hsla], background: Hsla) -> f32 { + if accents.len() < 2 { + return f32::MAX; + } + accents + .iter() + .copied() + .zip(accents.iter().copied().cycle().skip(1)) + .take(accents.len()) + .map(|(left, right)| adjacent_distance(left, right, background)) + .fold(f32::MAX, f32::min) +} + +fn oklab_distance(left: Oklab, right: Oklab) -> f32 { + let dl = left.l - right.l; + let da = left.a - right.a; + let db = left.b - right.b; + (dl * dl + da * da + db * db).sqrt() +} + +fn adjacent_distance(left: Hsla, right: Hsla, background: Hsla) -> f32 { + oklab_distance( + hsla_to_oklab(background.blend(left)), + hsla_to_oklab(background.blend(right)), + ) +} + +fn adjust_color_for_background( + color: Hsla, + background: Hsla, + minimum_background_contrast: f32, +) -> Hsla { + if background_contrast(color, background) >= minimum_background_contrast { + return color; + } + + let original = hsla_to_oklab(color); + let darker_candidate = adjusted_lightness_candidate( + color, + background, + minimum_background_contrast, + LIGHTNESS_CLAMP_MIN, + ); + let lighter_candidate = adjusted_lightness_candidate( + color, + background, + minimum_background_contrast, + LIGHTNESS_CLAMP_MAX, + ); + + match (darker_candidate, lighter_candidate) { + (Some(darker_candidate), Some(lighter_candidate)) => { + let darker_distance = oklab_distance(original, hsla_to_oklab(darker_candidate)); + let lighter_distance = oklab_distance(original, hsla_to_oklab(lighter_candidate)); + if darker_distance <= lighter_distance { + darker_candidate + } else { + lighter_candidate + } + } + (Some(darker_candidate), None) => darker_candidate, + (None, Some(lighter_candidate)) => lighter_candidate, + (None, None) => color, + } +} + +fn adjusted_lightness_candidate( + color: Hsla, + background: Hsla, + minimum_background_contrast: f32, + target_lightness: f32, +) -> Option { + let original = hsla_to_oklch(color); + let lightness_delta = target_lightness - original.l; + + if lightness_delta.abs() <= f32::EPSILON { + return None; + } + + (1..=128).find_map(|step| { + let amount = step as f32 / 128.0; + let candidate = oklch_to_hsla( + Oklch { + l: (original.l + lightness_delta * amount).clamp(0.0, 1.0), + chroma: original.chroma, + hue: original.hue, + }, + color.a, + ); + (background_contrast(candidate, background) >= minimum_background_contrast) + .then_some(candidate) + }) +} + +fn background_contrast(foreground: Hsla, background: Hsla) -> f32 { + apca_contrast(background.blend(foreground), background).abs() +} + fn compute_bracket_ranges( multi_buffer_snapshot: &MultiBufferSnapshot, buffer_snapshot: &BufferSnapshot, @@ -201,7 +397,7 @@ mod tests { }; use collections::HashSet; use fs::FakeFs; - use gpui::UpdateGlobal as _; + use gpui::{Rgba, UpdateGlobal as _, hsla}; use indoc::indoc; use itertools::Itertools; use language::{Capability, markdown_lang}; @@ -213,10 +409,154 @@ mod tests { use serde_json::json; use settings::{AccentContent, SettingsStore}; use text::{Bias, OffsetRangeExt, ToOffset}; + use theme::Appearance; use theme_settings::ThemeStyleContent; + use ui::ActiveTheme; use util::{path, post_inc}; + fn light_editor_background() -> Hsla { + hsla(0.0, 0.0, 0.98, 1.0) + } + + fn dark_editor_background() -> Hsla { + hsla(0.0, 0.0, 0.12, 1.0) + } + + #[test] + fn test_auto_bracket_colorization_mode_reorders_weak_palette() { + let accents = vec![ + hsla(0.0, 1.0, 0.68, 1.0), + hsla(0.02, 1.0, 0.68, 1.0), + hsla(0.34, 1.0, 0.68, 1.0), + hsla(0.36, 1.0, 0.68, 1.0), + ]; + + let original_min_adj = min_adjacent_oklab_distance(&accents, dark_editor_background()); + let reordered = maximize_adjacent_separation(&accents, dark_editor_background()); + let reordered_min_adj = min_adjacent_oklab_distance(&reordered, dark_editor_background()); + + assert_ne!(reordered.as_slice(), accents.as_slice()); + assert!(reordered_min_adj > original_min_adj); + } + + #[test] + fn test_preserves_strong_palette() { + let accents = vec![ + hsla(0.0, 1.0, 0.78, 1.0), + hsla(0.16, 1.0, 0.78, 1.0), + hsla(0.33, 1.0, 0.78, 1.0), + hsla(0.66, 1.0, 0.78, 1.0), + ]; + + let palette = + bracket_colorization_accents(&accents, Appearance::Dark, dark_editor_background()); + + assert_eq!(palette.as_ref(), accents.as_slice()); + } + + #[test] + fn test_adjusts_background_failures_preserving_hue_and_chroma() { + let accents = vec![ + hsla(0.58, 1.0, 0.28, 1.0), + hsla(0.12, 1.0, 0.28, 1.0), + hsla(0.22, 0.9, 0.76, 1.0), + ]; + + let palette = + bracket_colorization_accents(&accents, Appearance::Light, light_editor_background()); + let original = hsla_to_oklch(accents[2]); + let adjusted = hsla_to_oklch(palette[2]); + + assert_ne!(palette.as_ref(), accents.as_slice()); + assert_eq!(palette.len(), accents.len()); + assert_eq!(palette[0], accents[0]); + assert_eq!(palette[1], accents[1]); + assert_ne!(palette[2], accents[2]); + assert!((original.chroma - adjusted.chroma).abs() < 0.0001); + assert!((original.hue - adjusted.hue).abs() < 0.001); + assert_ne!(original.l, adjusted.l); + assert!( + background_contrast(palette[2], light_editor_background()) >= BACKGROUND_APCA_LIGHT + ); + } + + #[test] + fn test_preserves_light_near_miss_palette() { + let accents = vec![ + Hsla::from(Rgba::try_from("#CC241D").expect("valid color")), + Hsla::from(Rgba::try_from("#98971A").expect("valid color")), + Hsla::from(Rgba::try_from("#D79921").expect("valid color")), + Hsla::from(Rgba::try_from("#458588").expect("valid color")), + Hsla::from(Rgba::try_from("#B16286").expect("valid color")), + Hsla::from(Rgba::try_from("#689D6A").expect("valid color")), + Hsla::from(Rgba::try_from("#D65D0E").expect("valid color")), + ]; + let background = Hsla::from(Rgba::try_from("#FBF1C7").expect("valid color")); + + let palette = bracket_colorization_accents(&accents, Appearance::Light, background); + let original_min_adj = min_adjacent_oklab_distance(&accents, background); + + assert_eq!(palette.as_ref(), accents.as_slice()); + assert!(original_min_adj < ADJACENT_OKLAB_LIGHT); + assert!(original_min_adj >= ADJACENT_OKLAB_LIGHT_INTERVENTION); + } + + #[test] + fn test_adjust_color_for_background_prefers_closest_passing_candidate() { + // Verify that when both darker and lighter candidates exist, + // we pick the one with minimum OKLab distance from the original. + let background = hsla(0.0, 0.0, 0.50, 1.0); + let min_contrast = 30.0; + let color = hsla(0.33, 0.7, 0.46, 1.0); + + assert!( + background_contrast(color, background) < min_contrast, + "test color must fail contrast check; got {}", + background_contrast(color, background) + ); + + let original = hsla_to_oklab(color); + let darker = + adjusted_lightness_candidate(color, background, min_contrast, LIGHTNESS_CLAMP_MIN) + .expect("fixture must produce a darker passing candidate"); + let lighter = + adjusted_lightness_candidate(color, background, min_contrast, LIGHTNESS_CLAMP_MAX) + .expect("fixture must produce a lighter passing candidate"); + + let darker_dist = oklab_distance(original, hsla_to_oklab(darker)); + let lighter_dist = oklab_distance(original, hsla_to_oklab(lighter)); + let expected = if darker_dist <= lighter_dist { + darker + } else { + lighter + }; + assert_eq!( + adjust_color_for_background(color, background, min_contrast), + expected + ); + } + + #[test] + fn test_background_adjustment_edge_cases() { + let color = hsla(0.22, 0.9, 0.76, 1.0); + let original_contrast = background_contrast(color, light_editor_background()); + assert!(original_contrast < 20.0); + let palette = + bracket_colorization_accents(&[color], Appearance::Light, light_editor_background()); + assert_ne!(palette.as_ref(), &[color][..]); + assert!( + background_contrast(palette[0], light_editor_background()) >= BACKGROUND_APCA_LIGHT + ); + + let impossible_color = hsla(0.58, 1.0, 0.47, 1.0); + let impossible_bg = hsla(0.0, 0.0, 0.50, 1.0); + assert_eq!( + adjust_color_for_background(impossible_color, impossible_bg, 200.0), + impossible_color + ); + } + #[gpui::test] async fn test_basic_bracket_colorization(cx: &mut gpui::TestAppContext) { init_test(cx, |language_settings| { @@ -291,11 +631,11 @@ where 2 }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) -4 hsla(187.00, 47.00%, 59.22%, 1.00) -5 hsla(355.00, 65.00%, 75.94%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) +4 hsla(187.00, 47.00%, 55.00%, 1.00) +5 hsla(355.00, 65.00%, 65.00%, 1.00) 6 hsla(95.00, 38.00%, 62.00%, 1.00) 7 hsla(39.00, 67.00%, 69.00%, 1.00) "#, @@ -327,7 +667,7 @@ where assert_eq!( "fn main«1()1» «1{}1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) ", editor .update(cx, |editor, window, cx| { @@ -356,7 +696,7 @@ where assert_eq!( r#"«1[LLM-powered features]1»«1(./ai/overview.md)1», «1[bring and configure your own API keys]1»«1(./ai/llm-providers.md#use-your-own-keys)1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) "#, &bracket_colors_markup(&mut cx), "All markdown brackets should be colored based on their depth" @@ -368,8 +708,8 @@ where assert_eq!( r#"«1{«2{}2»}1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) "#, &bracket_colors_markup(&mut cx), "All markdown brackets should be colored based on their depth, again" @@ -384,7 +724,7 @@ where cx.executor().run_until_parked(); assert_eq!( - "«1('')1»«1('')1»\n\n«1(«2('')2»)1»«1('')1»\n\n«1('')1»«1(«2('')2»)1»\n1 hsla(207.80, 16.20%, 69.19%, 1.00)\n2 hsla(29.00, 54.00%, 65.88%, 1.00)\n", + "«1('')1»«1('')1»\n\n«1(«2('')2»)1»«1('')1»\n\n«1('')1»«1(«2('')2»)1»\n1 hsla(207.80, 81.00%, 66.00%, 1.00)\n2 hsla(29.00, 54.00%, 61.00%, 1.00)\n", &bracket_colors_markup(&mut cx), "Markdown quote pairs should not interfere with parenthesis pairing" ); @@ -403,7 +743,7 @@ where .await; let rows = 100; - let footer = "1 hsla(207.80, 16.20%, 69.19%, 1.00)\n"; + let footer = "1 hsla(207.80, 81.00%, 66.00%, 1.00)\n"; let simple_brackets = (0..rows).map(|_| "ˇ[]\n").collect::(); let simple_brackets_highlights = (0..rows).map(|_| "«1[]1»\n").collect::(); @@ -477,8 +817,8 @@ where let v: Vec = vec!«2[]2»; }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) "#, &bracket_colors_markup(&mut cx), "Markdown does not colorize <> brackets" @@ -495,8 +835,8 @@ where let v: Vec«22» = vec!«2[]2»; }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) "#, &bracket_colors_markup(&mut cx), "After switching to Rust, <> brackets are now colorized" @@ -540,9 +880,9 @@ fn process_data«1()1» «1{ let map: Result< }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) "#}, &bracket_colors_markup(&mut cx), "Brackets without pairs should be ignored and not colored" @@ -563,9 +903,9 @@ fn process_data«1()1» «1{ let map: Result2» }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) "#}, &bracket_colors_markup(&mut cx), "When brackets start to get closed, inner brackets are re-colored based on their depth" @@ -608,10 +948,10 @@ fn process_data«1()1» «1{ let map: Result3»>2» }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) -4 hsla(187.00, 47.00%, 59.22%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) +4 hsla(187.00, 47.00%, 55.00%, 1.00) "#}, &bracket_colors_markup(&mut cx), ); @@ -631,11 +971,11 @@ fn process_data«1()1» «1{ let map: Result«24»>3», «3()3»>2» = unimplemented!«2()2»; }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) -4 hsla(187.00, 47.00%, 59.22%, 1.00) -5 hsla(355.00, 65.00%, 75.94%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) +4 hsla(187.00, 47.00%, 55.00%, 1.00) +5 hsla(355.00, 65.00%, 65.00%, 1.00) "#}, &bracket_colors_markup(&mut cx), ); @@ -687,11 +1027,11 @@ mod foo «1{ } }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) -4 hsla(187.00, 47.00%, 59.22%, 1.00) -5 hsla(355.00, 65.00%, 75.94%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) +4 hsla(187.00, 47.00%, 55.00%, 1.00) +5 hsla(355.00, 65.00%, 65.00%, 1.00) "#}, comment_lines, ), @@ -719,11 +1059,11 @@ mod foo «1{ }2» }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) -4 hsla(187.00, 47.00%, 59.22%, 1.00) -5 hsla(355.00, 65.00%, 75.94%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) +4 hsla(187.00, 47.00%, 55.00%, 1.00) +5 hsla(355.00, 65.00%, 65.00%, 1.00) "#}, comment_lines, ), @@ -750,11 +1090,11 @@ mod foo «1{ } «3{«4{}4»}3»}2»}1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) -4 hsla(187.00, 47.00%, 59.22%, 1.00) -5 hsla(355.00, 65.00%, 75.94%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) +4 hsla(187.00, 47.00%, 55.00%, 1.00) +5 hsla(355.00, 65.00%, 65.00%, 1.00) "#}, comment_lines, ), @@ -781,11 +1121,11 @@ mod foo «1{ } «3{«4{}4»}3»}2»}1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) -4 hsla(187.00, 47.00%, 59.22%, 1.00) -5 hsla(355.00, 65.00%, 75.94%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) +4 hsla(187.00, 47.00%, 55.00%, 1.00) +5 hsla(355.00, 65.00%, 65.00%, 1.00) "#}, comment_lines, ), @@ -842,11 +1182,11 @@ mod foo «1{ } {{}}}}1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) -4 hsla(187.00, 47.00%, 59.22%, 1.00) -5 hsla(355.00, 65.00%, 75.94%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) +4 hsla(187.00, 47.00%, 55.00%, 1.00) +5 hsla(355.00, 65.00%, 65.00%, 1.00) "#, comment_lines, ), @@ -1353,11 +1693,11 @@ mod foo «1{ }2» }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) -4 hsla(187.00, 47.00%, 59.22%, 1.00) -5 hsla(355.00, 65.00%, 75.94%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) +4 hsla(187.00, 47.00%, 55.00%, 1.00) +5 hsla(355.00, 65.00%, 65.00%, 1.00) "#,}, &editor_bracket_colors_markup(&editor_snapshot), "Multi buffers should have their brackets colored even if no excerpts contain the bracket counterpart (after fn `process_data_2()`) \ @@ -1393,11 +1733,11 @@ mod foo «1{ }2» }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) -4 hsla(187.00, 47.00%, 59.22%, 1.00) -5 hsla(355.00, 65.00%, 75.94%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) +4 hsla(187.00, 47.00%, 55.00%, 1.00) +5 hsla(355.00, 65.00%, 65.00%, 1.00) "#,}, &editor_bracket_colors_markup(&editor_snapshot), ); @@ -1424,7 +1764,18 @@ mod foo «1{ let editor_snapshot = editor .update(cx, |editor, window, cx| editor.snapshot(window, cx)) .unwrap(); - assert_eq!( + let adjusted_palette = cx.update(|cx| { + bracket_colorization_accents( + &[ + Hsla::from(Rgba::try_from("#ff0000").expect("valid override accent")), + Hsla::from(Rgba::try_from("#0000ff").expect("valid override accent")), + ], + cx.theme().appearance, + cx.theme().colors().editor_background, + ) + }); + let expected_markup = format!( + "{}\n1 {}\n2 {}\n", indoc! {r#" @@ -1442,11 +1793,13 @@ mod foo «1{ let other_map: Option«12»>1» = None; }2» }1» - -1 hsla(0.00, 100.00%, 78.12%, 1.00) -2 hsla(240.00, 100.00%, 82.81%, 1.00) "#,}, - &editor_bracket_colors_markup(&editor_snapshot), + adjusted_palette[0], + adjusted_palette[1], + ); + assert_eq!( + expected_markup, + editor_bracket_colors_markup(&editor_snapshot), "After updating theme accents, the editor should update the bracket coloring" ); } @@ -1536,10 +1889,10 @@ mod foo «1{ " let other_map: Option\u{00ab}23\u{00bb}>2\u{00bb} = None;\n", "}1\u{00bb}\n", "\n", - "1 hsla(207.80, 16.20%, 69.19%, 1.00)\n", - "2 hsla(29.00, 54.00%, 65.88%, 1.00)\n", - "3 hsla(286.00, 51.00%, 75.25%, 1.00)\n", - "4 hsla(187.00, 47.00%, 59.22%, 1.00)\n", + "1 hsla(207.80, 81.00%, 66.00%, 1.00)\n", + "2 hsla(29.00, 54.00%, 61.00%, 1.00)\n", + "3 hsla(286.00, 51.00%, 64.00%, 1.00)\n", + "4 hsla(187.00, 47.00%, 55.00%, 1.00)\n", ), &editor_bracket_colors_markup(&editor_snapshot), "Two close excerpts from the same buffer (within same tree-sitter chunk) should both have bracket colors" @@ -1592,9 +1945,9 @@ fn small_function«1()1» «1{ let x = «2(1, «3(2, 3)3»)2»; }1» -1 hsla(207.80, 16.20%, 69.19%, 1.00) -2 hsla(29.00, 54.00%, 65.88%, 1.00) -3 hsla(286.00, 51.00%, 75.25%, 1.00) +1 hsla(207.80, 81.00%, 66.00%, 1.00) +2 hsla(29.00, 54.00%, 61.00%, 1.00) +3 hsla(286.00, 51.00%, 64.00%, 1.00) "#,}, bracket_colors_markup(&mut cx), ); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 12138cb7f92..70b4911e17c 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -9640,6 +9640,13 @@ impl Editor { let theme_settings = theme_settings::ThemeSettings::get_global(cx); let theme = cx.theme(); let accent_colors = theme.accents().clone(); + let editor_background = theme.colors().editor_background; + let auto_accent_colors = + AccentColors(crate::bracket_colorization::bracket_colorization_accents( + &accent_colors.0, + theme.appearance, + editor_background, + )); let accent_overrides = theme_settings .theme_overrides @@ -9659,7 +9666,7 @@ impl Editor { .collect(); Some(AccentData { - colors: accent_colors, + colors: auto_accent_colors, overrides: accent_overrides, }) } diff --git a/crates/theme/src/color_space.rs b/crates/theme/src/color_space.rs new file mode 100644 index 00000000000..125dfa6d9d6 --- /dev/null +++ b/crates/theme/src/color_space.rs @@ -0,0 +1,76 @@ +//! Conversions between gpui's [`Hsla`] and the OKLab / OKLCh perceptual color +//! spaces, backed by the `palette` crate. +//! +//! These are exposed so consumers can reason about perceptual color distance +//! (e.g. bracket colorization) without taking a direct dependency on `palette`. + +use gpui::{Hsla, Rgba}; +use palette::{ + FromColor, OklabHue, + rgb::{LinSrgba, Srgba}, +}; + +/// A color in the OKLab perceptual color space. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Oklab { + /// Perceptual lightness, in `0.0..=1.0`. + pub l: f32, + /// Green/red opponent axis. + pub a: f32, + /// Blue/yellow opponent axis. + pub b: f32, +} + +/// A color in the OKLCh perceptual color space (the cylindrical form of OKLab). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Oklch { + /// Perceptual lightness, in `0.0..=1.0`. + pub l: f32, + /// Chroma (colorfulness). + pub chroma: f32, + /// Hue, in degrees (`0.0..360.0`). + pub hue: f32, +} + +/// Converts an [`Hsla`] color into the OKLab color space. +pub fn hsla_to_oklab(color: Hsla) -> Oklab { + let oklab = palette::Oklab::from_color(hsla_to_linear(color)); + Oklab { + l: oklab.l, + a: oklab.a, + b: oklab.b, + } +} + +/// Converts an [`Hsla`] color into the OKLCh color space. +pub fn hsla_to_oklch(color: Hsla) -> Oklch { + let oklch = palette::Oklch::from_color(hsla_to_linear(color)); + Oklch { + l: oklch.l, + chroma: oklch.chroma, + hue: oklch.hue.into_positive_degrees(), + } +} + +/// Converts an [`Oklch`] color back into [`Hsla`], using `alpha` for the +/// resulting alpha channel. Channels outside the sRGB gamut are clamped. +pub fn oklch_to_hsla(color: Oklch, alpha: f32) -> Hsla { + let oklch = palette::Oklch { + l: color.l, + chroma: color.chroma, + hue: OklabHue::from_degrees(color.hue), + }; + let rgba: Srgba = Srgba::from_linear(LinSrgba::from_color(oklch)); + let (red, green, blue, _) = rgba.into_components(); + Hsla::from(Rgba { + r: red.clamp(0.0, 1.0), + g: green.clamp(0.0, 1.0), + b: blue.clamp(0.0, 1.0), + a: alpha, + }) +} + +fn hsla_to_linear(color: Hsla) -> LinSrgba { + let rgba = Rgba::from(color); + Srgba::new(rgba.r, rgba.g, rgba.b, rgba.a).into_linear() +} diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index ab49877c6bb..b973d8f25c1 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -8,6 +8,7 @@ //! //! A theme is a collection of colors used to build a consistent appearance for UI components across the application. +mod color_space; mod default_colors; mod fallback_themes; mod font_family_cache; @@ -30,6 +31,7 @@ use gpui::{ }; use serde::Deserialize; +pub use crate::color_space::*; pub use crate::default_colors::*; pub use crate::fallback_themes::{apply_status_color_defaults, apply_theme_color_defaults}; pub use crate::font_family_cache::*; From b206841b4b17f742d8a9028a526aea79ffb44360 Mon Sep 17 00:00:00 2001 From: G36maid <53391375+G36maid@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:36:36 +0800 Subject: [PATCH 006/197] Add range-based whitespace and newline removal to buffer formatting (#53942) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary When formatting specific ranges (e.g. via Format Selections), trailing whitespace removal and final newline enforcement currently operate on the entire file. This PR makes these operations range-aware, so they only affect lines within the target ranges. As noted in #16509, users want `remove_trailing_whitespace_on_save` and `ensure_final_newline_on_save` to suppress jitter by only touching changed lines. This PR provides the foundational infrastructure for that — range-based whitespace and newline operations — which a follow-up PR will wire into the `format_on_save` modifications mode. ## Changes - Added `remove_trailing_whitespace_in_ranges` and `ensure_final_newline_in_range` to `Buffer` - When a `FormattableBuffer` has ranges set, whitespace and newline operations now use the range-based variants - `format_ranges_via_lsp` returns `Ok(None)` instead of an error when the LSP doesn't support range formatting - Extracted `anchor_ranges_to_row_ranges` helper to deduplicate conversion logic
Tests (4) - `test_trailing_whitespace_in_ranges` — only modified lines have whitespace removed - `test_trailing_whitespace_empty_ranges` — empty ranges → no changes - `test_final_newline_modified_last_line` — newline added when last line is in range - `test_final_newline_unmodified_last_line` — newline not added when last line is outside range
--- 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: - Improved Format Selection to scope trailing whitespace removal and final newline enforcement to the selected range instead of the entire file - Improved handling of LSP servers that do not support range formatting by gracefully skipping instead of producing an error --------- Co-authored-by: Kirill Bulatov --- crates/language/src/buffer.rs | 112 ++++++++++---- crates/language/src/buffer_tests.rs | 226 +++++++++++++++++++++++++++- crates/project/src/lsp_store.rs | 53 +++++-- 3 files changed, 350 insertions(+), 41 deletions(-) diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 413c50d2627..d41143d12f5 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -2279,14 +2279,21 @@ impl Buffer { }) } - /// Spawns a background task that searches the buffer for any whitespace - /// at the ends of a lines, and returns a `Diff` that removes that whitespace. - pub fn remove_trailing_whitespace(&self, cx: &App) -> Task { + /// Spawns a background task that returns a `Diff` removing trailing whitespace from line ends. + /// + /// When `modified_rows` is `Some`, only lines whose row falls within one of the given ranges + /// are trimmed; when it is `None`, the whole buffer is scanned. + pub fn remove_trailing_whitespace( + &self, + modified_rows: Option<&[Range]>, + cx: &App, + ) -> Task { let old_text = self.as_rope().clone(); let line_ending = self.line_ending(); let base_version = self.version(); + let modified_rows = modified_rows.map(|rows| rows.to_vec()); cx.background_spawn(async move { - let ranges = trailing_whitespace_ranges(&old_text); + let ranges = trailing_whitespace_ranges(&old_text, modified_rows.as_deref()); let empty = Arc::::from(""); Diff { base_version, @@ -2299,28 +2306,60 @@ impl Buffer { }) } - /// Ensures that the buffer ends with a single newline character, and - /// no other whitespace. Skips if the buffer is empty. - pub fn ensure_final_newline(&mut self, cx: &mut Context) { + /// Returns a `Diff` ensuring the buffer ends with a trailing newline. + /// + /// When `modified_rows` is `None`, the whole buffer is considered: trailing whitespace and + /// blank lines at the end of the file are collapsed into a single newline. + /// + /// When `modified_rows` is `Some`, the operation is scoped to a "Format Selection": a single + /// newline is appended only when the last line is non-empty and its row falls within one of + /// the ranges. Trailing blank lines are left intact so that formatting a selection cannot + /// delete unselected rows. + pub fn ensure_final_newline(&self, modified_rows: Option<&[Range]>) -> Diff { let len = self.len(); - if len == 0 { - return; - } - let mut offset = len; - for chunk in self.as_rope().reversed_chunks_in_range(0..len) { - let non_whitespace_len = chunk - .trim_end_matches(|c: char| c.is_ascii_whitespace()) - .len(); - offset -= chunk.len(); - offset += non_whitespace_len; - if non_whitespace_len != 0 { - if offset == len - 1 && chunk.get(non_whitespace_len..) == Some("\n") { - return; - } - break; + let line_ending = self.line_ending(); + let base_version = self.version(); + let newline = Arc::::from("\n"); + + let edits = if len == 0 { + Vec::new() + } else if let Some(modified_rows) = modified_rows { + let max_point = self.max_point(); + let last_line_is_empty = max_point.column == 0; + let last_row_is_modified = modified_rows + .iter() + .any(|range| range.contains(&max_point.row)); + if last_line_is_empty || !last_row_is_modified { + Vec::new() + } else { + Vec::from([(len..len, newline)]) } + } else { + let mut offset = len; + let mut already_normalized = false; + for chunk in self.as_rope().reversed_chunks_in_range(0..len) { + let non_whitespace_len = chunk + .trim_end_matches(|c: char| c.is_ascii_whitespace()) + .len(); + offset -= chunk.len(); + offset += non_whitespace_len; + if non_whitespace_len != 0 { + already_normalized = + offset == len - 1 && chunk.get(non_whitespace_len..) == Some("\n"); + break; + } + } + if already_normalized { + Vec::new() + } else { + Vec::from([(offset..len, newline)]) + } + }; + Diff { + base_version, + line_ending, + edits, } - self.edit([(offset..len, "\n")], None, cx); } /// Applies a diff to the buffer. If the buffer has changed since the given diff was @@ -6133,10 +6172,24 @@ impl CharClassifier { /// /// This could also be done with a regex search, but this implementation /// avoids copying text. -pub fn trailing_whitespace_ranges(rope: &Rope) -> Vec> { +/// Returns the byte ranges of trailing whitespace at the end of each line. +/// +/// When `row_ranges` is `Some`, only lines whose row falls within one of the ranges are +/// included. The single pass keeps filtering cheap, avoiding collecting every range up front. +pub(crate) fn trailing_whitespace_ranges( + rope: &Rope, + row_ranges: Option<&[Range]>, +) -> Vec> { let mut ranges = Vec::new(); + let is_row_included = |row: u32| match row_ranges { + Some(row_ranges) => row_ranges.iter().any(|range| range.contains(&row)), + None => true, + }; + let mut offset = 0; + let mut current_row: u32 = 0; + let mut prev_row: Option = None; let mut prev_chunk_trailing_whitespace_range = 0..0; for chunk in rope.chunks() { let mut prev_line_trailing_whitespace_range = 0..0; @@ -6148,19 +6201,24 @@ pub fn trailing_whitespace_ranges(rope: &Rope) -> Vec> { if i == 0 && trimmed_line_len == 0 { trailing_whitespace_range.start = prev_chunk_trailing_whitespace_range.start; } - if !prev_line_trailing_whitespace_range.is_empty() { - ranges.push(prev_line_trailing_whitespace_range); + if let Some(row) = prev_row { + if !prev_line_trailing_whitespace_range.is_empty() && is_row_included(row) { + ranges.push(prev_line_trailing_whitespace_range); + } } + prev_row = Some(current_row); offset = line_end_offset + 1; + current_row += 1; prev_line_trailing_whitespace_range = trailing_whitespace_range; } offset -= 1; + current_row -= 1; prev_chunk_trailing_whitespace_range = prev_line_trailing_whitespace_range; } - if !prev_chunk_trailing_whitespace_range.is_empty() { + if !prev_chunk_trailing_whitespace_range.is_empty() && is_row_included(current_row) { ranges.push(prev_chunk_trailing_whitespace_range); } diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index ba7fd7ea957..cdcd90314cd 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -571,7 +571,7 @@ async fn test_normalize_whitespace(cx: &mut gpui::TestAppContext) { // Spawn a task to format the buffer's whitespace. // Pause so that the formatting task starts running. - let format = buffer.update(cx, |buffer, cx| buffer.remove_trailing_whitespace(cx)); + let format = buffer.update(cx, |buffer, cx| buffer.remove_trailing_whitespace(None, cx)); yield_now().await; // Edit the buffer while the normalization task is running. @@ -3792,7 +3792,7 @@ fn test_trailing_whitespace_ranges(mut rng: StdRng) { } let rope = Rope::from(text.as_str()); - let actual_ranges = trailing_whitespace_ranges(&rope); + let actual_ranges = trailing_whitespace_ranges(&rope, None); let expected_ranges = TRAILING_WHITESPACE_REGEX .find_iter(&text) .map(|m| m.range()) @@ -3805,6 +3805,228 @@ fn test_trailing_whitespace_ranges(mut rng: StdRng) { ); } +#[gpui::test(iterations = 500)] +fn test_trailing_whitespace_ranges_in_rows(mut rng: StdRng) { + let mut text = String::new(); + for _ in 0..rng.random_range(0..16) { + for _ in 0..rng.random_range(0..36) { + text.push(match rng.random_range(0..10) { + 0..=1 => ' ', + 3 => '\t', + _ => rng.random_range('a'..='z'), + }); + } + text.push('\n'); + } + match rng.random_range(0..10) { + 0..=1 => drop(text.pop()), + 2..=3 => text.push_str(&"\n".repeat(rng.random_range(1..5))), + _ => {} + } + + let rope = Rope::from(text.as_str()); + let all_ranges = trailing_whitespace_ranges(&rope, None); + let lines = text.split('\n').collect::>(); + + // A range covering every row must reproduce the unfiltered full scan exactly. + assert_eq!( + trailing_whitespace_ranges(&rope, Some(&[0..u32::MAX])), + all_ranges, + "full-coverage mismatch for lines:\n{lines:?}", + ); + + // For a random (possibly gappy) subset of rows, the filtered variant must equal + // the full scan restricted to ranges whose line is in the subset. + let max_row = rope.max_point().row; + let mut row_ranges = Vec::new(); + let mut row = 0; + while row <= max_row { + let span = rng.random_range(0..=3); + if span > 0 { + let end = (row + span).min(max_row + 1); + row_ranges.push(row..end); + row = end; + } + row += 1; + } + + let expected = all_ranges + .iter() + .filter(|range| { + let row = rope.offset_to_point(range.start).row; + row_ranges.iter().any(|r| r.contains(&row)) + }) + .cloned() + .collect::>(); + assert_eq!( + trailing_whitespace_ranges(&rope, Some(&row_ranges)), + expected, + "subset mismatch for ranges {row_ranges:?} and lines:\n{lines:?}", + ); +} + +#[gpui::test] +async fn test_trailing_whitespace_in_ranges(cx: &mut gpui::TestAppContext) { + // line 0: "zero" (no trailing whitespace) + // line 1: "one " (2 trailing spaces) + // line 2: "two" (no trailing whitespace) + // line 3: "three " (3 trailing spaces) + // line 4: "four" (no trailing whitespace) + // line 5: "five " (4 trailing spaces) + let text = ["zero", "one ", "two", "three ", "four", "five "].join("\n"); + let buffer = cx.new(|cx| Buffer::local(text, cx)); + + // Only rows 1 and 5 are modified, so only those lines get cleaned; line 3 stays untouched. + let modified_rows = [1u32..2, 5..6]; + let diff = buffer + .update(cx, |buffer, cx| { + buffer.remove_trailing_whitespace(Some(&modified_rows), cx) + }) + .await; + buffer.update(cx, |buffer, cx| { + buffer.apply_diff(diff, cx); + assert_eq!( + buffer.text(), + ["zero", "one", "two", "three ", "four", "five"].join("\n") + ); + }); +} + +#[gpui::test] +async fn test_trailing_whitespace_empty_ranges(cx: &mut gpui::TestAppContext) { + let text = ["zero", "one ", "two "].join("\n"); + let buffer = cx.new(|cx| Buffer::local(text.clone(), cx)); + + let diff = buffer + .update(cx, |buffer, cx| { + buffer.remove_trailing_whitespace(Some(&[]), cx) + }) + .await; + buffer.update(cx, |buffer, cx| { + buffer.apply_diff(diff, cx); + assert_eq!(buffer.text(), text); + }); +} + +#[gpui::test] +async fn test_final_newline_modified_last_line(cx: &mut gpui::TestAppContext) { + // No final newline; the modified range (rows 0..3) includes the last line (row 2). + let text = "line0\nline1\nline2"; + let buffer = cx.new(|cx| Buffer::local(text, cx)); + + buffer.update(cx, |buffer, cx| { + let diff = buffer.ensure_final_newline(Some(&[0u32..3])); + buffer.apply_diff(diff, cx); + assert_eq!(buffer.text(), "line0\nline1\nline2\n"); + }); +} + +#[gpui::test] +async fn test_final_newline_unmodified_last_line(cx: &mut gpui::TestAppContext) { + // No final newline; the modified range (rows 0..2) excludes the last line (row 2), so nothing changes. + let text = "line0\nline1\nline2"; + let buffer = cx.new(|cx| Buffer::local(text, cx)); + + buffer.update(cx, |buffer, cx| { + let diff = buffer.ensure_final_newline(Some(&[0u32..2])); + buffer.apply_diff(diff, cx); + assert_eq!(buffer.text(), "line0\nline1\nline2"); + }); +} + +// An empty last line (file already ends with a newline) is left untouched, even with extra +// trailing blank lines. With `None` these would collapse; scoped to rows they must not, to +// avoid deleting unselected rows. +#[gpui::test] +async fn test_final_newline_does_not_collapse_trailing_blank_lines(cx: &mut gpui::TestAppContext) { + let text = "line0\nline1\n\n"; + let buffer = cx.new(|cx| Buffer::local(text, cx)); + + buffer.update(cx, |buffer, cx| { + let diff = buffer.ensure_final_newline(Some(&[0u32..4])); + buffer.apply_diff(diff, cx); + assert_eq!(buffer.text(), "line0\nline1\n\n"); + }); +} + +// When scoped to rows, only a newline is inserted; unlike the `None` (whole-buffer) case, it +// does not trim trailing whitespace on the last line. +#[gpui::test] +async fn test_final_newline_in_range_only_inserts(cx: &mut gpui::TestAppContext) { + let text = "line0\nline1 "; + let buffer = cx.new(|cx| Buffer::local(text, cx)); + + buffer.update(cx, |buffer, cx| { + let diff = buffer.ensure_final_newline(Some(&[0u32..2])); + buffer.apply_diff(diff, cx); + assert_eq!(buffer.text(), "line0\nline1 \n"); + }); +} + +#[gpui::test] +async fn test_final_newline_whole_buffer(cx: &mut gpui::TestAppContext) { + // (input, expected) pairs for the whole-buffer (`None`) case. + let cases = [ + // Content without a trailing newline gets exactly one appended. + ("line0\nline1", "line0\nline1\n"), + // A buffer already ending in a single newline is left untouched. + ("line0\nline1\n", "line0\nline1\n"), + // Trailing blank lines and whitespace at the end of the file collapse to one newline. + ("line0\nline1\n\n\n", "line0\nline1\n"), + ("line0\nline1 \n ", "line0\nline1\n"), + // An empty buffer stays empty. + ("", ""), + ]; + + for (input, expected) in cases { + let buffer = cx.new(|cx| Buffer::local(input, cx)); + buffer.update(cx, |buffer, cx| { + let diff = buffer.ensure_final_newline(None); + buffer.apply_diff(diff, cx); + assert_eq!(buffer.text(), expected, "wrong result for input {input:?}"); + }); + } +} + +#[gpui::test] +async fn test_trailing_whitespace_in_ranges_crlf(cx: &mut gpui::TestAppContext) { + let text = "zero\r\none \r\ntwo\r\nthree \r\nfour\r\nfive "; + let buffer = cx.new(|cx| { + let buffer = Buffer::local(text, cx); + assert_eq!(buffer.line_ending(), LineEnding::Windows); + buffer + }); + + let modified_rows = [1u32..2, 5..6]; + let diff = buffer + .update(cx, |buffer, cx| { + buffer.remove_trailing_whitespace(Some(&modified_rows), cx) + }) + .await; + buffer.update(cx, |buffer, cx| { + buffer.apply_diff(diff, cx); + assert_eq!(buffer.text(), "zero\none\ntwo\nthree \nfour\nfive"); + assert_eq!(buffer.line_ending(), LineEnding::Windows); + }); +} + +#[gpui::test] +async fn test_final_newline_in_range_crlf(cx: &mut gpui::TestAppContext) { + let text = "line0\r\nline1\r\nline2"; + let buffer = cx.new(|cx| { + let buffer = Buffer::local(text, cx); + assert_eq!(buffer.line_ending(), LineEnding::Windows); + buffer + }); + + buffer.update(cx, |buffer, cx| { + let diff = buffer.ensure_final_newline(Some(&[0u32..3])); + buffer.apply_diff(diff, cx); + assert_eq!(buffer.text(), "line0\nline1\nline2\n"); + assert_eq!(buffer.line_ending(), LineEnding::Windows); + }); +} + #[gpui::test] fn test_words_in_range(cx: &mut gpui::App) { init_settings(cx, |_| {}); diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index feb1936c47a..e7ac11e773b 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -1635,12 +1635,36 @@ impl LocalLspStore { .handle .read_with(cx, |buffer, _| buffer.max_point().row > 0); + // When formatting a selection, only the rows it spans may be touched. + let selection_row_ranges = buffer.ranges.as_ref().map(|ranges| { + buffer.handle.read_with(cx, |buffer, _cx| { + let snapshot = buffer.snapshot(); + ranges + .iter() + .map(|range| { + let start = range.start.to_point(&snapshot); + let end = range.end.to_point(&snapshot); + // A selection ending at column 0 of a row only includes that row's + // preceding line break, not its content, so it shouldn't be trimmed. + let end_row = if end.column == 0 && end.row > start.row { + end.row + } else { + end.row + 1 + }; + start.row..end_row + }) + .collect::>() + }) + }); + // handle whitespace formatting if settings.remove_trailing_whitespace_on_save { zlog::trace!(logger => "removing trailing whitespace"); let diff = buffer .handle - .read_with(cx, |buffer, cx| buffer.remove_trailing_whitespace(cx)) + .read_with(cx, |buffer, cx| { + buffer.remove_trailing_whitespace(selection_row_ranges.as_deref(), cx) + }) .await; extend_formatting_transaction(buffer, formatting_transaction_id, cx, |buffer, cx| { buffer.apply_diff(diff, cx); @@ -1649,8 +1673,11 @@ impl LocalLspStore { if settings.ensure_final_newline_on_save { zlog::trace!(logger => "ensuring final newline"); + let diff = buffer.handle.read_with(cx, |buffer, _cx| { + buffer.ensure_final_newline(selection_row_ranges.as_deref()) + }); extend_formatting_transaction(buffer, formatting_transaction_id, cx, |buffer, cx| { - buffer.ensure_final_newline(cx); + buffer.apply_diff(diff, cx); })?; } @@ -1925,6 +1952,7 @@ impl LocalLspStore { ) .await .context("Failed to format ranges via language server")? + .unwrap_or_default() } else { zlog::trace!(logger => "formatting full"); Self::format_via_lsp( @@ -2175,12 +2203,8 @@ impl LocalLspStore { formatting_transaction_id, cx, |buffer, cx| { - zlog::info!( - "Applying edits {edits:?}. Content: {:?}", - buffer.text() - ); + zlog::trace!("Applying {} edits", edits.len()); buffer.edit(edits, None, cx); - zlog::info!("Applied edits. New Content: {:?}", buffer.text()); }, )?; } @@ -2322,14 +2346,18 @@ impl LocalLspStore { language_server: &Arc, settings: &LanguageSettings, cx: &mut AsyncApp, - ) -> Result, Arc)>> { + ) -> Result, Arc)>>> { let capabilities = &language_server.capabilities(); let range_formatting_provider = capabilities.document_range_formatting_provider.as_ref(); - if range_formatting_provider == Some(&OneOf::Left(false)) { - anyhow::bail!( - "{} language server does not support range formatting", + if !matches!( + range_formatting_provider, + Some(OneOf::Left(true) | OneOf::Right(_)) + ) { + log::debug!( + "Skipping range formatting: language server {} does not support range formatting", language_server.name() ); + return Ok(None); } let uri = file_path_to_lsp_url(abs_path)?; @@ -2388,8 +2416,9 @@ impl LocalLspStore { ) })? .await + .map(Some) } else { - Ok(Vec::with_capacity(0)) + Ok(Some(Vec::with_capacity(0))) } } From d0802abdecadabc5c3248ebf75a466831f6dfbe4 Mon Sep 17 00:00:00 2001 From: "( Nechiforel David-Samuel ) NsdHSO" Date: Wed, 1 Jul 2026 20:36:57 +0300 Subject: [PATCH 007/197] grammars: Fix runnable gutter detection for `describe.skipIf` / `test.skipIf` in JS/TS/TSX (#60153) I came across issue #60149 and wanted to help. The runnable gutter (play button) was not showing for Vitest's conditional test wrappers like it.skipIf(condition) and it.runIf(condition). image image image Closes #60149 Release Notes: - Fixed runnable gutter detection for Vitest conditional test wrappers (skipIf/runIf) in JS/TS/TSX files --------- Co-authored-by: Kirill Bulatov --- crates/grammars/src/javascript/outline.scm | 14 +- crates/grammars/src/javascript/runnables.scm | 14 +- crates/grammars/src/tsx/outline.scm | 14 +- crates/grammars/src/tsx/runnables.scm | 14 +- crates/grammars/src/typescript/outline.scm | 14 +- crates/grammars/src/typescript/runnables.scm | 14 +- crates/languages/src/typescript.rs | 178 +++++++++++++++++++ 7 files changed, 250 insertions(+), 12 deletions(-) diff --git a/crates/grammars/src/javascript/outline.scm b/crates/grammars/src/javascript/outline.scm index ce6d9e6bd94..c87c53cbc28 100644 --- a/crates/grammars/src/javascript/outline.scm +++ b/crates/grammars/src/javascript/outline.scm @@ -169,6 +169,7 @@ name: (_) @name) @item ; Add support for (node:test, bun:test and Jest) runnable +; Also matches direct modifiers: .skip, .todo, .only, .failing (Jest, Bun, Vitest) ((call_expression function: [ (identifier) @_name @@ -188,7 +189,10 @@ (identifier) @name ]))) @item -; Add support for parameterized tests +; Parameterized and conditional tests. Docs per runner: +; Jest: https://jestjs.io/docs/api#testeachtablename-fn-timeout +; Vitest: https://vitest.dev/api/ +; Bun: https://bun.sh/docs/test/writing-tests#test-modifiers ((call_expression function: (call_expression function: (member_expression @@ -199,7 +203,13 @@ ] property: (property_identifier) @_property) (#any-of? @_name "it" "test" "describe" "context" "suite") - (#eq? @_property "each")) + (#any-of? @_property + ; Jest, Bun, Vitest + "each" + ; Vitest + "skipIf" "runIf" + ; Bun + "if" "todoIf")) arguments: (arguments . [ diff --git a/crates/grammars/src/javascript/runnables.scm b/crates/grammars/src/javascript/runnables.scm index b410fb4d8ca..8b0b160c862 100644 --- a/crates/grammars/src/javascript/runnables.scm +++ b/crates/grammars/src/javascript/runnables.scm @@ -1,5 +1,6 @@ ; Add support for (node:test, bun:test and Jest) runnable ; Function expression that has `it`, `test` or `describe` as the function name +; Also matches direct modifiers: .skip, .todo, .only, .failing (Jest, Bun, Vitest) ((call_expression function: [ (identifier) @_name @@ -20,7 +21,10 @@ ])) @_js-test (#set! tag js-test)) -; Add support for parameterized tests +; Parameterized and conditional tests. Docs per runner: +; Jest: https://jestjs.io/docs/api#testeachtablename-fn-timeout +; Vitest: https://vitest.dev/api/ +; Bun: https://bun.sh/docs/test/writing-tests#test-modifiers ((call_expression function: (call_expression function: (member_expression @@ -31,7 +35,13 @@ ] property: (property_identifier) @_property) (#any-of? @_name "it" "test" "describe" "context" "suite") - (#eq? @_property "each")) + (#any-of? @_property + ; Jest, Bun, Vitest + "each" + ; Vitest + "skipIf" "runIf" + ; Bun + "if" "todoIf")) arguments: (arguments . [ diff --git a/crates/grammars/src/tsx/outline.scm b/crates/grammars/src/tsx/outline.scm index 2c08c30ad58..19099b0076a 100644 --- a/crates/grammars/src/tsx/outline.scm +++ b/crates/grammars/src/tsx/outline.scm @@ -175,6 +175,7 @@ name: (_) @name) @item ; Add support for (node:test, bun:test and Jest) runnable +; Also matches direct modifiers: .skip, .todo, .only, .failing (Jest, Bun, Vitest) ((call_expression function: [ (identifier) @_name @@ -194,7 +195,10 @@ (identifier) @name ]))) @item -; Add support for parameterized tests +; Parameterized and conditional tests. Docs per runner: +; Jest: https://jestjs.io/docs/api#testeachtablename-fn-timeout +; Vitest: https://vitest.dev/api/ +; Bun: https://bun.sh/docs/test/writing-tests#test-modifiers ((call_expression function: (call_expression function: (member_expression @@ -205,7 +209,13 @@ ] property: (property_identifier) @_property) (#any-of? @_name "it" "test" "describe" "context" "suite") - (#any-of? @_property "each")) + (#any-of? @_property + ; Jest, Bun, Vitest + "each" + ; Vitest + "skipIf" "runIf" + ; Bun + "if" "todoIf")) arguments: (arguments . [ diff --git a/crates/grammars/src/tsx/runnables.scm b/crates/grammars/src/tsx/runnables.scm index db1f69a2c22..8b0b160c862 100644 --- a/crates/grammars/src/tsx/runnables.scm +++ b/crates/grammars/src/tsx/runnables.scm @@ -1,5 +1,6 @@ ; Add support for (node:test, bun:test and Jest) runnable ; Function expression that has `it`, `test` or `describe` as the function name +; Also matches direct modifiers: .skip, .todo, .only, .failing (Jest, Bun, Vitest) ((call_expression function: [ (identifier) @_name @@ -20,7 +21,10 @@ ])) @_js-test (#set! tag js-test)) -; Add support for parameterized tests +; Parameterized and conditional tests. Docs per runner: +; Jest: https://jestjs.io/docs/api#testeachtablename-fn-timeout +; Vitest: https://vitest.dev/api/ +; Bun: https://bun.sh/docs/test/writing-tests#test-modifiers ((call_expression function: (call_expression function: (member_expression @@ -31,7 +35,13 @@ ] property: (property_identifier) @_property) (#any-of? @_name "it" "test" "describe" "context" "suite") - (#any-of? @_property "each")) + (#any-of? @_property + ; Jest, Bun, Vitest + "each" + ; Vitest + "skipIf" "runIf" + ; Bun + "if" "todoIf")) arguments: (arguments . [ diff --git a/crates/grammars/src/typescript/outline.scm b/crates/grammars/src/typescript/outline.scm index 2c08c30ad58..19099b0076a 100644 --- a/crates/grammars/src/typescript/outline.scm +++ b/crates/grammars/src/typescript/outline.scm @@ -175,6 +175,7 @@ name: (_) @name) @item ; Add support for (node:test, bun:test and Jest) runnable +; Also matches direct modifiers: .skip, .todo, .only, .failing (Jest, Bun, Vitest) ((call_expression function: [ (identifier) @_name @@ -194,7 +195,10 @@ (identifier) @name ]))) @item -; Add support for parameterized tests +; Parameterized and conditional tests. Docs per runner: +; Jest: https://jestjs.io/docs/api#testeachtablename-fn-timeout +; Vitest: https://vitest.dev/api/ +; Bun: https://bun.sh/docs/test/writing-tests#test-modifiers ((call_expression function: (call_expression function: (member_expression @@ -205,7 +209,13 @@ ] property: (property_identifier) @_property) (#any-of? @_name "it" "test" "describe" "context" "suite") - (#any-of? @_property "each")) + (#any-of? @_property + ; Jest, Bun, Vitest + "each" + ; Vitest + "skipIf" "runIf" + ; Bun + "if" "todoIf")) arguments: (arguments . [ diff --git a/crates/grammars/src/typescript/runnables.scm b/crates/grammars/src/typescript/runnables.scm index 38fee610e85..b2321b816ca 100644 --- a/crates/grammars/src/typescript/runnables.scm +++ b/crates/grammars/src/typescript/runnables.scm @@ -1,5 +1,6 @@ ; Add support for (node:test, bun:test, Jest and Deno.test) runnable ; Function expression that has `it`, `test` or `describe` as the function name +; Also matches direct modifiers: .skip, .todo, .only, .failing (Jest, Bun, Vitest) ((call_expression function: [ (identifier) @_name @@ -20,7 +21,10 @@ ])) @_js-test (#set! tag js-test)) -; Add support for parameterized tests +; Parameterized and conditional tests. Docs per runner: +; Jest: https://jestjs.io/docs/api#testeachtablename-fn-timeout +; Vitest: https://vitest.dev/api/ +; Bun: https://bun.sh/docs/test/writing-tests#test-modifiers ((call_expression function: (call_expression function: (member_expression @@ -31,7 +35,13 @@ ] property: (property_identifier) @_property) (#any-of? @_name "it" "test" "describe" "context" "suite") - (#any-of? @_property "each")) + (#any-of? @_property + ; Jest, Bun, Vitest + "each" + ; Vitest + "skipIf" "runIf" + ; Bun + "if" "todoIf")) arguments: (arguments . [ diff --git a/crates/languages/src/typescript.rs b/crates/languages/src/typescript.rs index 5572e43f049..e8d18840e0f 100644 --- a/crates/languages/src/typescript.rs +++ b/crates/languages/src/typescript.rs @@ -1433,6 +1433,184 @@ mod tests { ); } + #[gpui::test] + async fn test_conditional_test_wrappers(cx: &mut TestAppContext) { + for language in [ + crate::language( + "typescript", + tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + ), + crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()), + crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into()), + ] { + let text = r#" + it.runIf(true)("runIf test", () => { + true; + }); + + it.skipIf(false)("skipIf test", () => { + true; + }); + + test.runIf(true)("runIf test 2", () => { + true; + }); + + test.skipIf(false)("skipIf test 2", () => { + true; + }); + + describe.runIf(true)("runIf describe", () => { + it("inner test", () => { + true; + }); + }); + + describe.skipIf(false)("skipIf describe", () => { + it("inner test 2", () => { + true; + }); + }); + + it.todoIf(false)("todoIf test", () => { + true; + }); + + it.if(true)("if test", () => { + true; + }); + + test.todoIf(false)("todoIf test 2", () => { + true; + }); + + test.if(true)("if test 2", () => { + true; + }); + + describe.todoIf(false)("todoIf describe", () => { + it("inner todoIf", () => { + true; + }); + }); + + describe.if(true)("if describe", () => { + it("inner if", () => { + true; + }); + }); + + test.failing("failing test", () => { + true; + }); + + it.failing("failing it", () => { + true; + }); + + it.each([1, 2, 3])("each test", () => { + true; + }); + + describe.each([1, 2])("each describe", () => { + it("inner each", () => { + true; + }); + }); + + it.skip("skip test", () => { + true; + }); + + it.only("only test", () => { + true; + }); + + it.todo("todo test"); + "# + .unindent(); + + let text_len = text.len(); + let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx)); + cx.executor().run_until_parked(); + + let outline = buffer.update(cx, |buffer, _cx| buffer.snapshot().outline(None)); + let outline_names = outline + .items + .iter() + .map(|item| item.text.as_str()) + .collect::>(); + assert_eq!( + outline_names, + [ + "runIf test", + "skipIf test", + "runIf test 2", + "skipIf test 2", + "runIf describe", + "it inner test", + "skipIf describe", + "it inner test 2", + "todoIf test", + "if test", + "todoIf test 2", + "if test 2", + "todoIf describe", + "it inner todoIf", + "if describe", + "it inner if", + "test.failing failing test", + "it.failing failing it", + "each test", + "each describe", + "it inner each", + "it.skip skip test", + "it.only only test", + "it.todo todo test", + ] + ); + + let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); + let runnable_names = snapshot + .runnable_ranges(0..text_len) + .map(|runnable| { + snapshot + .text_for_range(runnable.run_range) + .collect::() + }) + .collect::>(); + assert_eq!( + runnable_names, + [ + "runIf test", + "skipIf test", + "runIf test 2", + "skipIf test 2", + "runIf describe", + "inner test", + "skipIf describe", + "inner test 2", + "todoIf test", + "if test", + "todoIf test 2", + "if test 2", + "todoIf describe", + "inner todoIf", + "if describe", + "inner if", + "failing test", + "failing it", + "each test", + "each describe", + "inner each", + "skip test", + "only test", + "todo test", + ] + ); + } + } + #[gpui::test] async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) { cx.update(|cx| { From 10b07951838e422722e34641f4a9c0bfec9037ff Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Wed, 1 Jul 2026 23:52:24 +0100 Subject: [PATCH 008/197] sandbox: Fix WSL downloaded binary path (#60210) WSL downloads a linux zed binary for the sandbox helper. But the flag is set on the `zed-editor` binary, not the `zed` cli binary. This fixes that --- Release Notes: - N/A or Added/Fixed/Improved ... --- crates/sandbox/src/windows_wsl.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/sandbox/src/windows_wsl.rs b/crates/sandbox/src/windows_wsl.rs index 2979a98852b..b5cc8694b12 100644 --- a/crates/sandbox/src/windows_wsl.rs +++ b/crates/sandbox/src/windows_wsl.rs @@ -81,7 +81,9 @@ const HELPER_RESULT_PREFIX: &str = "zed-wsl-helper:"; /// (`~/.local/libexec/zed/`, the conventional spot for executables run /// by other programs rather than directly by the user). One managed copy per /// channel is kept, tracked by a marker file so an exact channel+version match -/// is reused rather than re-downloaded. +/// is reused rather than re-downloaded. The floating `latest` version (dev +/// builds) is the exception: it always re-downloads so it tracks the newest +/// nightly rather than pinning to the first copy fetched. /// /// We ship no `zed` (nor `bwrap`) into WSL ourselves; this downloads `zed` on /// demand. A missing `curl`/`wget` (or a failed download) is a hard error the @@ -94,9 +96,11 @@ dest="$HOME/.local/libexec/zed/$channel" marker="$dest/.zed-wsl-helper-version" want="$channel $version" -# Reuse an exact, already-installed channel+version. -if [ "$(cat "$marker" 2>/dev/null || true)" = "$want" ]; then - helper=$(find "$dest" -type f -path '*/bin/zed' -print 2>/dev/null | head -n 1 || true) +# Reuse an exact, already-installed channel+version — but never for the floating +# "latest" tag (dev builds), which must always re-fetch so they track the most +# recent nightly instead of pinning to whatever was downloaded first. +if [ "$version" != "latest" ] && [ "$(cat "$marker" 2>/dev/null || true)" = "$want" ]; then + helper=$(find "$dest" -type f -path '*/libexec/zed-editor' -print 2>/dev/null | head -n 1 || true) if [ -n "$helper" ] && [ -x "$helper" ]; then printf 'zed-wsl-helper: %s\n' "$helper" exit 0 @@ -125,9 +129,9 @@ fi mkdir -p "$tmp/unpacked" tar -xzf "$tarball" -C "$tmp/unpacked" -helper_src=$(find "$tmp/unpacked" -type f -path '*/bin/zed' -print 2>/dev/null | head -n 1 || true) +helper_src=$(find "$tmp/unpacked" -type f -path '*/libexec/zed-editor' -print 2>/dev/null | head -n 1 || true) if [ -z "$helper_src" ]; then - echo 'the downloaded zed tarball did not contain a bin/zed binary' >&2 + echo 'the downloaded zed tarball did not contain a libexec/zed-editor binary' >&2 exit 1 fi app=$(dirname "$(dirname "$helper_src")") @@ -144,7 +148,7 @@ mv "$dest.new" "$dest" rm -rf "$dest.old" printf '%s' "$want" > "$marker" -helper=$(find "$dest" -type f -path '*/bin/zed' -print 2>/dev/null | head -n 1 || true) +helper=$(find "$dest" -type f -path '*/libexec/zed-editor' -print 2>/dev/null | head -n 1 || true) if [ -z "$helper" ] || [ ! -x "$helper" ]; then echo "the installed zed sandbox helper is missing or not executable under $dest" >&2 exit 1 From 995e56d2639eff49ebdb6e988d01f1593b14aff5 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:04:24 -0300 Subject: [PATCH 009/197] markdown_preview: Use UI font for base font size (#60212) This PR makes the `markdown_preview_font_size` setting be based on the UI font size instead of the buffer UI font size. I typically use a much smaller code font than UI font, and that made reading the markdown preview super small. I don't think this will be uncommon because the dimensions between mono and non-mono typefaces are different... Release Notes: - Changed the markdown preview to fall back to the UI font size instead of the buffer font size when `markdown_preview_font_size` is unset. --- assets/settings/default.json | 2 +- crates/markdown/src/markdown.rs | 16 +++++++--------- crates/settings_content/src/theme.rs | 2 +- crates/theme_settings/src/settings.rs | 8 ++++---- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 74560dd49bb..74c6252fe73 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -73,7 +73,7 @@ "agent_buffer_font_size": 12, // The default font size for the commit editor in the git panel and commit modal. "git_commit_buffer_font_size": 12, - // The default font size for the markdown preview. Falls back to the editor font size if unset. + // The default font size for the markdown preview. Falls back to the UI font size if unset. "markdown_preview_font_size": null, // The font family for the markdown preview. Falls back to the UI font family if unset. "markdown_preview_font_family": null, diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 89266065a1c..024314ebdd4 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -5399,13 +5399,13 @@ mod tests { } #[gpui::test] - fn test_editor_zoom_does_not_affect_markdown_preview(cx: &mut TestAppContext) { + fn test_ui_zoom_does_not_affect_markdown_preview(cx: &mut TestAppContext) { ensure_theme_initialized(cx); cx.update(|cx| { settings::SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { - settings.theme.buffer_font_size = Some(16.0.into()); + settings.theme.ui_font_size = Some(16.0.into()); settings.theme.markdown_preview_font_size = None; }); }); @@ -5416,11 +5416,9 @@ mod tests { let before = ThemeSettings::get_global(cx).markdown_preview_font_size(cx); assert_eq!(before, px(16.0)); - theme_settings::increase_buffer_font_size(cx); - theme_settings::increase_buffer_font_size(cx); - theme_settings::increase_buffer_font_size(cx); + theme_settings::adjust_ui_font_size(cx, |size| size + px(3.0)); - assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(19.0)); + assert_eq!(ThemeSettings::get_global(cx).ui_font_size(cx), px(19.0)); assert_eq!( ThemeSettings::get_global(cx).markdown_preview_font_size(cx), before @@ -5429,13 +5427,13 @@ mod tests { } #[gpui::test] - fn test_markdown_preview_follows_buffer_font_size_setting_when_unset(cx: &mut TestAppContext) { + fn test_markdown_preview_follows_ui_font_size_setting_when_unset(cx: &mut TestAppContext) { ensure_theme_initialized(cx); cx.update(|cx| { settings::SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { - settings.theme.buffer_font_size = Some(20.0.into()); + settings.theme.ui_font_size = Some(20.0.into()); settings.theme.markdown_preview_font_size = None; }); }); @@ -5451,7 +5449,7 @@ mod tests { cx.update(|cx| { settings::SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { - settings.theme.buffer_font_size = Some(24.0.into()); + settings.theme.ui_font_size = Some(24.0.into()); }); }); }); diff --git a/crates/settings_content/src/theme.rs b/crates/settings_content/src/theme.rs index 6c40f210c89..406c1ead91b 100644 --- a/crates/settings_content/src/theme.rs +++ b/crates/settings_content/src/theme.rs @@ -157,7 +157,7 @@ pub struct ThemeSettingsContent { /// markdown preview. Falls back to the buffer font if unset. pub markdown_preview_code_font_family: Option, /// The font size to use for rendering in the markdown preview. - /// Falls back to the buffer font size if unset. + /// Falls back to the UI font size if unset. pub markdown_preview_font_size: Option, /// The theme to use for the markdown preview. /// Falls back to the main editor theme if unset. diff --git a/crates/theme_settings/src/settings.rs b/crates/theme_settings/src/settings.rs index 5deadcc108a..517367af2b8 100644 --- a/crates/theme_settings/src/settings.rs +++ b/crates/theme_settings/src/settings.rs @@ -64,7 +64,7 @@ pub struct ThemeSettings { /// Falls back to the buffer font family if unset. markdown_preview_code_font_family: Option, /// The font size to use for rendering in the markdown preview. - /// Falls back to the buffer font size if unset. + /// Falls back to the UI font size if unset. markdown_preview_font_size: Option, /// The theme to use for the markdown preview. /// Falls back to the main editor theme if unset. @@ -451,14 +451,14 @@ impl ThemeSettings { /// Returns the markdown preview font size. /// - /// Note: the fallback deliberately uses `self.buffer_font_size` instead of `buffer_font_size(cx)`, - /// so that temporary editor zoom does not also resize the markdown preview. + /// Note: the fallback deliberately uses `self.ui_font_size` instead of `ui_font_size(cx)`, + /// so that temporary UI zoom does not also resize the markdown preview. pub fn markdown_preview_font_size(&self, cx: &App) -> Pixels { cx.try_global::() .map(|size| size.0) .or(self.markdown_preview_font_size) .map(clamp_font_size) - .unwrap_or_else(|| clamp_font_size(self.buffer_font_size)) + .unwrap_or_else(|| clamp_font_size(self.ui_font_size)) } /// Returns the buffer font size, read from the settings. From d132afe9fc440dc6822d6cba7ee34fa1d71056de Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Thu, 2 Jul 2026 13:12:58 +0530 Subject: [PATCH 010/197] Add new area labels to track mapping - 2 (#60247) | Label | Description | |---|---| | `area:ai/agent thread/sandbox` | Feedback for Zed's Agent sandboxing | | `area:text finder` | Issue about text finder | | `area:gpui/graphics` | Related to graphics issues in GPUI | Release Notes: - N/A --- script/community-pr-track-mapping.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/script/community-pr-track-mapping.json b/script/community-pr-track-mapping.json index 6559890c0d3..479ef876baf 100644 --- a/script/community-pr-track-mapping.json +++ b/script/community-pr-track-mapping.json @@ -62,7 +62,7 @@ }, { "name": "GPUI & Input", - "labels": ["area:controls/ime", "area:controls/mouse", "area:gpui"] + "labels": ["area:controls/ime", "area:controls/mouse", "area:gpui", "area:gpui/graphics"] }, { "name": "Auth & Zed.dev", @@ -97,6 +97,7 @@ "area:outline", "area:project panel", "area:scanning", + "area:text finder", "area:workspace" ] }, @@ -143,6 +144,7 @@ "area:ai", "area:ai/acp", "area:ai/agent thread", + "area:ai/agent thread/sandbox", "area:ai/agent thread/skills", "area:ai/anthropic", "area:ai/assistant", From 7d545c0baeff67d3427e53cc4f382eeb967e4119 Mon Sep 17 00:00:00 2001 From: Warya Wayne <126521894+WaryaWayne@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:00:19 -0400 Subject: [PATCH 011/197] markdown: Make linked images clickable (#59525) # Objective Make images nested inside markdown links (e.g., README badges) clickable, so they behave like normal markdown links. Previously, linked images rendered correctly but were not interactive in markdown previews: they did not show a pointer cursor, could not be clicked to open their target URL, and did not expose link actions through the context menu. ## Solution Changed the `MarkdownElement` to detect when an `` tag is inside a `` tag during rendering. When a linked image is rendered: - The wrapper `div` gets a `cursor_pointer` style and an `on_click` handler that opens the link URL (or delegates to the `on_url_click` callback if set). - The wrapper also captures right-click events to populate the context menu with "Copy Link" via `capture_for_context_menu`. - The `on_url_click` closure type was changed from `Box` to `Rc` to allow cloning across multiple handlers. - When a linked image fails to load, the fallback element's `on_click` now calls `cx.stop_propagation()` to prevent the event from bubbling to the parent link wrapper and opening the URL twice. The feature is gated behind `!self.style.prevent_mouse_interaction` so it respects existing interaction-prevention settings. ## Testing - `cargo test -p markdown`: 95 tests passed. - Manually verified in markdown preview that badge/image links open correctly and right-click shows "Copy Link". - Verified existing markdown tests continue to pass. ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - Fixed images inside markdown links not being clickable. https://github.com/user-attachments/assets/df2a8d8f-b002-47f9-b139-815ee713700b --------- Co-authored-by: Smit Barmase --- Cargo.lock | 1 + crates/markdown/Cargo.toml | 1 + crates/markdown/src/markdown.rs | 172 ++++++++++++++++++++++++++++---- 3 files changed, 157 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2445366c126..8037aa43f99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10665,6 +10665,7 @@ dependencies = [ "gpui", "gpui_platform", "html5ever 0.27.0", + "image", "language", "languages", "linkify", diff --git a/crates/markdown/Cargo.toml b/crates/markdown/Cargo.toml index 7443803346e..3db8d84b733 100644 --- a/crates/markdown/Cargo.toml +++ b/crates/markdown/Cargo.toml @@ -46,6 +46,7 @@ env_logger.workspace = true fs = {workspace = true, features = ["test-support"]} gpui = { workspace = true, features = ["test-support"] } gpui_platform = { workspace = true, features = ["wayland", "x11"] } +image.workspace = true language = { workspace = true, features = ["test-support"] } languages = { workspace = true, features = ["load-grammars"] } node_runtime.workspace = true diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 024314ebdd4..69f5aa32083 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -1221,7 +1221,7 @@ pub struct MarkdownElement { markdown: Entity, style: MarkdownStyle, code_block_renderer: CodeBlockRenderer, - on_url_click: Option>, + on_url_click: Option>, code_span_link: Option, on_source_click: Option, on_checkbox_toggle: Option, @@ -1280,7 +1280,7 @@ impl MarkdownElement { mut self, handler: impl Fn(SharedString, &mut Window, &mut App) + 'static, ) -> Self { - self.on_url_click = Some(Box::new(handler)); + self.on_url_click = Some(Rc::new(handler)); self } @@ -1378,18 +1378,58 @@ impl MarkdownElement { width: Option, height: Option, ) { - let image_element = div().min_w_0().child( - img(source) - .id(("markdown-image", range.start)) - .min_w_0() - .max_w_full() - .rounded_md() - .mr_1() - .mb_1() - .when_some(height, |this, height| this.h(height)) - .when_some(width, |this, width| this.w(width)) - .with_fallback(move || image_fallback_element(dest_url.clone(), alt_text.clone())), - ); + let enclosing_link_url = (builder.link_depth > 0) + .then(|| builder.rendered_links.last()) + .flatten() + .map(|link| link.destination_url.clone()); + let fallback_opens_image_url = enclosing_link_url.is_none(); + + let image_element = { + let wrapper = div().id(("markdown-image-link", range.start)).min_w_0(); + let wrapper = if !self.style.prevent_mouse_interaction + && let Some(url) = enclosing_link_url + { + let click_url = url.clone(); + let markdown = self.markdown.clone(); + let url_click = self.on_url_click.clone(); + wrapper + .cursor_pointer() + .on_click(move |_, window, cx| { + if let Some(ref on_url_click) = url_click { + on_url_click(click_url.clone(), window, cx); + } else { + cx.open_url(&click_url); + } + }) + .capture_any_mouse_down(move |event, _window, cx| { + if event.button == MouseButton::Right { + markdown.update(cx, |md, _| { + md.capture_for_context_menu(Some(url.clone()), None) + }); + } + }) + } else { + wrapper + }; + wrapper.child( + img(source) + .id(("markdown-image", range.start)) + .min_w_0() + .max_w_full() + .rounded_md() + .mr_1() + .mb_1() + .when_some(height, |this, height| this.h(height)) + .when_some(width, |this, width| this.w(width)) + .with_fallback(move || { + image_fallback_element( + dest_url.clone(), + alt_text.clone(), + fallback_opens_image_url, + ) + }), + ) + }; builder.push_image_child(image_element); } @@ -2795,7 +2835,11 @@ fn collect_image_alt_text( } } -fn image_fallback_element(dest_url: SharedString, alt_text: Option) -> AnyElement { +fn image_fallback_element( + dest_url: SharedString, + alt_text: Option, + open_image_url_on_click: bool, +) -> AnyElement { let link_label = alt_text .filter(|alt| !alt.is_empty()) .unwrap_or_else(|| dest_url.clone()); @@ -2804,13 +2848,15 @@ fn image_fallback_element(dest_url: SharedString, alt_text: Option div() .id("image-fallback") - .cursor_pointer() .min_w_0() .child(Label::new(label).color(Color::Warning).underline()) .tooltip(Tooltip::text( "Image failed to load. Open `zed: log` for more details.", )) - .on_click(move |_, _, cx| cx.open_url(&dest_url)) + .when(open_image_url_on_click, |this| { + this.cursor_pointer() + .on_click(move |_, _, cx| cx.open_url(&dest_url)) + }) .into_any_element() } @@ -5289,6 +5335,98 @@ mod tests { }); } + fn failing_image_source() -> ImageSource { + ImageSource::Custom(Arc::new(|_, _| { + Some(Err(gpui::ImageCacheError::Asset( + "failed to load image".into(), + ))) + })) + } + + fn loaded_image_source() -> ImageSource { + let buffer = image::ImageBuffer::from_pixel(16, 16, image::Rgba([0, 0, 0, 255])); + ImageSource::Render(Arc::new(gpui::RenderImage::new(SmallVec::from_elem( + image::Frame::new(buffer), + 1, + )))) + } + + fn open_markdown_image_test_window<'a>( + source: &str, + image_source: ImageSource, + cx: &'a mut TestAppContext, + ) -> &'a mut gpui::VisualTestContext { + struct ImageTestView { + markdown: Entity, + image_source: ImageSource, + } + + impl Render for ImageTestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let image_source = self.image_source.clone(); + div().size_full().child( + MarkdownElement::new(self.markdown.clone(), MarkdownStyle::default()) + .image_resolver(move |_| Some(image_source.clone())), + ) + } + } + + ensure_theme_initialized(cx); + + let source = source.to_string(); + let (_, cx) = cx.add_window_view(|_, cx| ImageTestView { + markdown: cx.new(|cx| Markdown::new(source.into(), None, None, cx)), + image_source, + }); + cx.run_until_parked(); + cx + } + + #[gpui::test] + fn test_clicking_image_fallback_opens_image_url(cx: &mut TestAppContext) { + let cx = open_markdown_image_test_window( + "![alt text](https://example.com/image.png)", + failing_image_source(), + cx, + ); + + cx.simulate_click(point(px(8.), px(8.)), gpui::Modifiers::default()); + assert_eq!( + cx.opened_url(), + Some("https://example.com/image.png".to_string()) + ); + } + + #[gpui::test] + fn test_clicking_image_fallback_inside_link_opens_link_url(cx: &mut TestAppContext) { + let cx = open_markdown_image_test_window( + "[![alt text](https://example.com/image.png)](https://example.com/link)", + failing_image_source(), + cx, + ); + + cx.simulate_click(point(px(8.), px(8.)), gpui::Modifiers::default()); + assert_eq!( + cx.opened_url(), + Some("https://example.com/link".to_string()) + ); + } + + #[gpui::test] + fn test_clicking_loaded_image_inside_link_opens_link_url(cx: &mut TestAppContext) { + let cx = open_markdown_image_test_window( + "[![alt text](https://example.com/image.png)](https://example.com/link)", + loaded_image_source(), + cx, + ); + + cx.simulate_click(point(px(8.), px(8.)), gpui::Modifiers::default()); + assert_eq!( + cx.opened_url(), + Some("https://example.com/link".to_string()) + ); + } + #[track_caller] fn assert_mappings(rendered: &RenderedText, expected: Vec>) { assert_eq!(rendered.lines.len(), expected.len(), "line count mismatch"); From d1e8c0b50f445ae34c4e49d59be88dab052f0d1a Mon Sep 17 00:00:00 2001 From: Remco Smits Date: Thu, 2 Jul 2026 11:08:18 +0200 Subject: [PATCH 012/197] git_store: Avoid redundant work in worktree git repository update (#60205) ## Objective While profiling https://github.com/zed-industries/zed/issues/60102 I noticed `GitStore::update_repositories_from_worktree` does unneeded work. It fires on every `WorktreeUpdatedGitRepositories` event, which under filesystem pressure (`rescan: user dropped`) repeats roughly once per second and can fire more frequently in a large monorepo with many repositories, since the redundant work scales with the number of repositories. ## Solution In `crates/project/src/git_store.rs`: - Drop the per-iteration `Arc` clone in the repo lookup; compare the borrowed reference instead. - Hoist the `is_trusted` (`can_trust`) computation out of the loop since it only depends on `worktree_id`. No behavior change is intended; this is purely reducing redundant work per event. ## Notes The *frequency* of these events originates upstream in `LocalWorktree::changed_repos` (`crates/worktree/src/worktree.rs`), which re-emits an `UpdatedGitRepository` whenever `git_dir_scan_id` changes on a full rescan. That path is intentionally left untouched here since it involves scan semantics; this PR only addresses the redundant work on the consumer side. ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - N/A --- crates/project/src/git_store.rs | 36 +++++++++++---------------------- 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 1edd3780307..778743163d6 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -1817,13 +1817,21 @@ impl GitStore { cx: &mut Context, ) { let mut removed_ids = Vec::new(); + + let is_trusted = TrustedWorktrees::try_get_global(cx) + .map(|trusted_worktrees| { + trusted_worktrees.update(cx, |trusted_worktrees, cx| { + trusted_worktrees.can_trust(&self.worktree_store, worktree_id, cx) + }) + }) + .unwrap_or(false); + for update in updated_git_repositories.iter() { if let Some((id, existing)) = self.repositories.iter().find(|(_, repo)| { - let existing_work_directory_abs_path = - repo.read(cx).work_directory_abs_path.clone(); - Some(&existing_work_directory_abs_path) + let existing_work_directory_abs_path = &repo.read(cx).work_directory_abs_path; + Some(existing_work_directory_abs_path) == update.old_work_directory_abs_path.as_ref() - || Some(&existing_work_directory_abs_path) + || Some(existing_work_directory_abs_path) == update.new_work_directory_abs_path.as_ref() }) { let repo_id = *id; @@ -1842,17 +1850,6 @@ impl GitStore { update.repository_dir_abs_path.clone() && let Some(common_dir_abs_path) = update.common_dir_abs_path.clone() { - let is_trusted = TrustedWorktrees::try_get_global(cx) - .map(|trusted_worktrees| { - trusted_worktrees.update(cx, |trusted_worktrees, cx| { - trusted_worktrees.can_trust( - &self.worktree_store, - worktree_id, - cx, - ) - }) - }) - .unwrap_or(false); existing.update(cx, |existing, cx| { existing.reinitialize_local_backend( new_work_directory_abs_path, @@ -1888,16 +1885,7 @@ impl GitStore { .. } = update { - let repository_dir_abs_path = repository_dir_abs_path.clone(); - let common_dir_abs_path = common_dir_abs_path.clone(); let id = RepositoryId(next_repository_id.fetch_add(1, atomic::Ordering::Release)); - let is_trusted = TrustedWorktrees::try_get_global(cx) - .map(|trusted_worktrees| { - trusted_worktrees.update(cx, |trusted_worktrees, cx| { - trusted_worktrees.can_trust(&self.worktree_store, worktree_id, cx) - }) - }) - .unwrap_or(false); let git_store = cx.weak_entity(); let repo = cx.new(|cx| { let mut repo = Repository::local( From f964172a69779353853b7aa63c944c9fa680ab7e Mon Sep 17 00:00:00 2001 From: Sudarsh <55168611+Sudarsh1010@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:41:26 +0530 Subject: [PATCH 013/197] project_panel: Wrap filenames in code spans in confirmation dialogs (#53068) Closes #53060 Filenames with double underscores (e.g. __init__.py) were rendered as Markdown bold in confirmation dialogs because the prompt renderer interprets the message as Markdown. Wrap filenames in backticks so they render as inline code instead. Release Notes: - Fixed filenames with double underscores rendering as bold in project panel confirmation dialogs image image image --------- Co-authored-by: Smit Barmase --- crates/project_panel/src/project_panel.rs | 6 +-- .../project_panel/src/project_panel_tests.rs | 38 +++++++++++++++++ crates/workspace/src/pane.rs | 42 ++++++++++++++----- 3 files changed, 73 insertions(+), 13 deletions(-) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index ef1981db60c..059f0685078 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -2350,7 +2350,7 @@ impl ProjectPanel { let file_name = entry.path.file_name()?.to_string(); let answer = if !action.skip_prompt { - let prompt = format!("Discard changes to {}?", file_name); + let prompt = format!("Discard changes to {}?", MarkdownInlineCode(&file_name)); Some(window.prompt(PromptLevel::Info, &prompt, None, &["Restore", "Cancel"], cx)) } else { None @@ -2570,7 +2570,7 @@ impl ProjectPanel { .iter() .map(|(_, _, path)| MarkdownInlineCode(path).to_string()) .take(CUTOFF_POINT) - .collect::>(); + .collect::>(); paths.truncate(CUTOFF_POINT); if truncated_path_counts == 1 { paths.push(".. 1 file not shown".into()); @@ -4541,7 +4541,7 @@ impl ProjectPanel { "already exists in the destination folder. ", "Do you want to replace it?" ), - filename + MarkdownInlineCode(filename) ); let answer = cx .update(|window, cx| { diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index d1a0f3b209e..3e4e3788da7 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -11060,3 +11060,41 @@ async fn test_delete_prompt_escapes_markdown_in_file_name(cx: &mut gpui::TestApp "Are you sure you want to permanently delete `__somefile__`?" ); } + +#[gpui::test] +async fn test_restore_file_prompt_escapes_markdown_in_file_name(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + ".git": {}, + "__init__.py": "modified contents", + }), + ) + .await; + fs.set_head_and_index_for_repo( + path!("/root/.git").as_ref(), + &[("__init__.py", "original contents".into())], + ); + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); + cx.run_until_parked(); + + select_path(&panel, "root/__init__.py", cx); + panel.update_in(cx, |panel, window, cx| { + panel.restore_file(&git::RestoreFile { skip_prompt: false }, window, cx) + }); + let (message, _detail) = cx + .pending_prompt() + .expect("restore should show a confirmation prompt"); + + assert_eq!(message, "Discard changes to `__init__.py`?"); +} diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index ea3091a6fff..55736c0824b 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -50,7 +50,8 @@ use ui::{ Tooltip, prelude::*, right_click_menu, }; use util::{ - ResultExt, debug_panic, maybe, paths::PathStyle, serde::default_true, truncate_and_remove_front, + ResultExt, debug_panic, markdown::MarkdownInlineCode, maybe, paths::PathStyle, + serde::default_true, truncate_and_remove_front, }; /// A selected entry in e.g. project panel. @@ -4891,15 +4892,20 @@ impl NavHistoryState { } fn dirty_message_for(buffer_path: Option, path_style: PathStyle) -> String { - let path = buffer_path - .as_ref() - .and_then(|p| { - let path = p.path.display(path_style); - if path.is_empty() { None } else { Some(path) } - }) - .unwrap_or("This buffer".into()); - let path = truncate_and_remove_front(&path, 80); - format!("{path} contains unsaved edits. Do you want to save it?") + let path = buffer_path.as_ref().and_then(|p| { + let path = p.path.display(path_style); + if path.is_empty() { None } else { Some(path) } + }); + match path { + Some(path) => { + let path = truncate_and_remove_front(&path, 80); + format!( + "{} contains unsaved edits. Do you want to save it?", + MarkdownInlineCode(path.as_str()) + ) + } + None => "This buffer contains unsaved edits. Do you want to save it?".to_string(), + } } pub fn tab_details(items: &[Box], _window: &Window, cx: &App) -> Vec { @@ -9093,6 +9099,22 @@ mod tests { } } + #[test] + fn test_dirty_message_for_escapes_markdown_in_path() { + let project_path = ProjectPath { + worktree_id: WorktreeId::from_usize(0), + path: util::rel_path::rel_path("dir/__init__.py").into(), + }; + assert_eq!( + dirty_message_for(Some(project_path), PathStyle::Posix), + "`dir/__init__.py` contains unsaved edits. Do you want to save it?" + ); + assert_eq!( + dirty_message_for(None, PathStyle::Posix), + "This buffer contains unsaved edits. Do you want to save it?" + ); + } + mod property_test { use super::*; use proptest::prelude::*; From 779ca5ef7248b1f66bad91f85a5c5d0b6922de6e Mon Sep 17 00:00:00 2001 From: Yue Fei Date: Thu, 2 Jul 2026 17:11:42 +0800 Subject: [PATCH 014/197] docs: Add note to Windows building docs (#60104) # Objective Microsoft has decoupled the MSVC version from the Visual Studio version starting with Visual Studio 2026 (see [this blog](https://devblogs.microsoft.com/cppblog/new-release-cadence-and-support-lifecycle-for-msvc-build-tools/)), meaning that the previous traditional naming conventions were obsolete. For Visual Studio 2026 (including future newer versions of VS) users, the existing docs will probably confuse them, as they cannot find out the components named like "MSVC v145 - VS 2026 C++ ..." in present Visual Studio Installer. ## Solution Add a note next to the dependencies. ## Testing Docs are no need to test. (Maybe?) ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- docs/src/development/windows.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/src/development/windows.md b/docs/src/development/windows.md index 38f578b36b8..b9882c51354 100644 --- a/docs/src/development/windows.md +++ b/docs/src/development/windows.md @@ -21,6 +21,8 @@ Clone the [Zed repository](https://github.com/zed-industries/zed). - Install the Windows 11 or 10 SDK for your system, and make sure at least `Windows 10 SDK version 2104 (10.0.20348.0)` is installed. You can download it from the [Windows SDK Archive](https://developer.microsoft.com/windows/downloads/windows-sdk/). - Install [CMake](https://cmake.org/download) (required by [a dependency](https://docs.rs/wasmtime-c-api-impl/latest/wasmtime_c_api/)). Or you can install it through Visual Studio Installer, then manually add the `bin` directory to your `PATH`, for example: `C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin`. +> Starting with Visual Studio 2026 (or MSVC 14.50), you need to install optional components `MSVC Build Tools for x64/x86 (Latest)` and `C++ Spectre-mitigated libraries for x64/x86 (Latest MSVC)`, since Microsoft has decoupled the MSVC version from the Visual Studio version. The traditional naming format `MSVC v*** - VS YYYY C++ x64/x86 ...` will no longer be applicable to newer MSVC toolchain. See [this blog](https://devblogs.microsoft.com/cppblog/new-release-cadence-and-support-lifecycle-for-msvc-build-tools/) for more details. + If you cannot compile Zed, make sure a Visual Studio installation includes at least the following components: ```json From 9cb3140ea10dbbf3ca5ece1ab9daf9ec3f44ba0b Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 2 Jul 2026 11:36:27 +0200 Subject: [PATCH 015/197] wgpu: Use wgpu from crates.io (#60264) Our patch made it upstream! So we can go back https://github.com/gfx-rs/wgpu/releases/tag/v29.0.4 Release Notes: - N/A --- Cargo.lock | 45 +++++++++++++++++++++++++++------------------ Cargo.toml | 2 +- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8037aa43f99..920c4e48122 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11270,8 +11270,9 @@ checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" [[package]] name = "naga" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" dependencies = [ "arrayvec", "bit-set 0.9.1", @@ -21037,8 +21038,9 @@ checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" [[package]] name = "wgpu" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07" dependencies = [ "arrayvec", "bitflags 2.10.0", @@ -21066,8 +21068,9 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" dependencies = [ "arrayvec", "bit-set 0.9.1", @@ -21098,32 +21101,36 @@ dependencies = [ [[package]] name = "wgpu-core-deps-apple" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e39e26c4c0e07589e67d18546cf79ff45383659fc72fca4dd293358a0347f3" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-emscripten" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01e09be551dc939498bdd5f6b2c66e55ab275dad25825267a08605a80fc9f0af" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d" dependencies = [ "android_system_properties", "arrayvec", @@ -21175,8 +21182,9 @@ dependencies = [ [[package]] name = "wgpu-naga-bridge" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161" dependencies = [ "naga", "wgpu-types", @@ -21184,8 +21192,9 @@ dependencies = [ [[package]] name = "wgpu-types" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5" dependencies = [ "bitflags 2.10.0", "bytemuck", diff --git a/Cargo.toml b/Cargo.toml index 80cc6501001..9f660975525 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -845,7 +845,7 @@ which = "6.0.0" wasm-bindgen = "0.2.120" web-time = "1.1.0" webrtc-sys = "0.3.23" -wgpu = { git = "https://github.com/zed-industries/wgpu.git", rev = "357a0c56e0070480ad9daea5d2eaa83150b79e88" } +wgpu = "29.0.4" windows-core = "0.61" yaml-rust2 = "0.8" yawc = "0.2.5" From b3d5ead59f39bdd7d5badf1a0a968522f960fa12 Mon Sep 17 00:00:00 2001 From: Lena <241371603+zelenenka@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:46:22 +0200 Subject: [PATCH 016/197] Add Guild board automation (#60266) Automates the Guild contributor program on project board #74 so the cohort is recognized and kept unblocked without manual babysitting. - Move a board issue to In Progress when a Guild member self-assigns it, and post a friendly heads-up when they are assigned an issue that is not on the board. - Flag when a Guild member opens a new PR while another of theirs is still open, to help them land work before spreading thin. - Check in on quiet assignments and, if an assignee stays silent, free the issue back to a to-do column so others can pick it up; a "guild hold" label lets maintainers pause check-ins after they have followed up. - Share a weekly digest of what the Guild shipped. - Label PRs from Guild members and recognize a Guild contributor tier on the community PR board. Release Notes: - N/A --- .github/workflows/guild_assignment_status.yml | 59 ++ .github/workflows/guild_new_pr_notify.yml | 115 ++++ .github/workflows/guild_stale_assignments.yml | 57 ++ .github/workflows/guild_weekly_shipped.yml | 55 ++ .github/workflows/pr_issue_labeler.yml | 51 +- ...ck_notify_community_automation_failure.yml | 4 + script/github-community-pr-board.py | 12 +- script/github-guild-board.py | 634 ++++++++++++++++++ 8 files changed, 940 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/guild_assignment_status.yml create mode 100644 .github/workflows/guild_new_pr_notify.yml create mode 100644 .github/workflows/guild_stale_assignments.yml create mode 100644 .github/workflows/guild_weekly_shipped.yml create mode 100644 script/github-guild-board.py diff --git a/.github/workflows/guild_assignment_status.yml b/.github/workflows/guild_assignment_status.yml new file mode 100644 index 00000000000..77f03168c4e --- /dev/null +++ b/.github/workflows/guild_assignment_status.yml @@ -0,0 +1,59 @@ +# Guild board (https://github.com/orgs/zed-industries/projects/74) reactions to issue events: +# assigned guild member -> Status "In Progress" (or Slack if off-board) +# unassigned guild member -> move back to a To-Do column by Type + Slack +# commented guild assignee comments after a check-in -> Slack (each comment) + +name: Guild Assignment Status + +on: + issues: + types: [assigned, unassigned] + issue_comment: + types: [created] + +permissions: + contents: read + +concurrency: + group: guild-assignment-status-${{ github.event.issue.number || github.run_id }} + cancel-in-progress: false + +jobs: + handle-event: + if: >- + github.repository == 'zed-industries/zed' && + (github.event_name != 'issue_comment' || github.event.issue.pull_request == null) + runs-on: namespace-profile-2x4-ubuntu-2404 + timeout-minutes: 5 + + steps: + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} + private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} + owner: zed-industries + + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + sparse-checkout: | + script/github-guild-board.py + sparse-checkout-cone-mode: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install requests + + - name: Handle issue event + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + PROJECT_NUMBER: "74" + GUILD_MODE: event + SLACK_WEBHOOK_GUILD_INTERNAL: ${{ secrets.SLACK_WEBHOOK_GUILD_INTERNAL }} + run: python script/github-guild-board.py diff --git a/.github/workflows/guild_new_pr_notify.yml b/.github/workflows/guild_new_pr_notify.yml new file mode 100644 index 00000000000..babab1c8b9c --- /dev/null +++ b/.github/workflows/guild_new_pr_notify.yml @@ -0,0 +1,115 @@ +# When a guild member opens a PR while already having another open PR in +# zed-industries/zed, post a heads-up to #zed-guild-internal. PRs opened before +# CUTOFF_DATE (the cohort start) don't count as "already open". + +name: Guild New PR Notification + +on: + pull_request_target: + types: [opened] + +permissions: + contents: read + +concurrency: + group: guild-new-pr-notify-${{ github.event.pull_request.number }} + cancel-in-progress: false + +env: + CUTOFF_DATE: "2026-07-01" + +jobs: + notify-slack: + if: github.repository == 'zed-industries/zed' + runs-on: namespace-profile-2x4-ubuntu-2404 + timeout-minutes: 5 + + steps: + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} + private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} + owner: zed-industries + + - name: Notify on an additional open PR by a guild member + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + env: + SLACK_WEBHOOK_GUILD_INTERNAL: ${{ secrets.SLACK_WEBHOOK_GUILD_INTERNAL }} + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const GUILD_TEAM_SLUG = 'guild-cohort-2'; + const pr = context.payload.pull_request; + const author = pr.user.login; + + const isGuildMember = async (username) => { + try { + const response = await github.rest.teams.getMembershipForUserInOrg({ + org: 'zed-industries', + team_slug: GUILD_TEAM_SLUG, + username + }); + return response.data.state === 'active'; + } catch (error) { + if (error.status === 404) { + return false; + } + throw error; + } + }; + + if (!(await isGuildMember(author))) { + console.log(`${author} is not a guild member, skipping`); + return; + } + + const cutoff = process.env.CUTOFF_DATE; + const query = `repo:zed-industries/zed is:pr is:open author:${author} created:>=${cutoff}`; + const result = await github.rest.search.issuesAndPullRequests({ q: query, per_page: 100 }); + const others = result.data.items.filter((item) => item.number !== pr.number); + + if (others.length === 0) { + console.log(`${author} has no other qualifying open PRs, skipping`); + return; + } + + const webhook = process.env.SLACK_WEBHOOK_GUILD_INTERNAL; + if (!webhook) { + core.setFailed('SLACK_WEBHOOK_GUILD_INTERNAL secret is not set'); + return; + } + + // Escape Slack's control characters so PR titles render literally + // instead of being read as links or @-mentions. + const esc = (text) => + (text || '').replace(/&/g, '&').replace(//g, '>'); + + const quips = [ + 'Deep into the queue they wander.', + 'Nevermore just one open PR.', + ]; + const quip = quips[Math.floor(Math.random() * quips.length)]; + const otherList = others + .map((item) => `<${item.html_url}|#${item.number}> ${esc(item.title)}`) + .join('\n• '); + const message = + `${quip} @${author} just opened <${pr.html_url}|${esc(pr.title)}> ` + + `while still having open PR(s):\n• ${otherList}\n` + + 'A gentle nudge to help them land one before spreading thin might be in order.'; + + const response = await fetch(webhook, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: `${author} opened another PR while others are still open`, + blocks: [{ type: 'section', text: { type: 'mrkdwn', text: message } }], + }), + }); + if (!response.ok) { + const body = await response.text(); + core.setFailed(`Slack notification failed with status ${response.status}: ${body}`); + return; + } + console.log(`Notified: ${author} has ${others.length} other open PR(s)`); diff --git a/.github/workflows/guild_stale_assignments.yml b/.github/workflows/guild_stale_assignments.yml new file mode 100644 index 00000000000..90b47988c3f --- /dev/null +++ b/.github/workflows/guild_stale_assignments.yml @@ -0,0 +1,57 @@ +# Scheduled sweep of the Guild board (https://github.com/orgs/zed-industries/projects/74). +# For an "In Progress" issue assigned to a guild member with no linked PR: post a +# check-in comment once the assignee goes quiet, re-nudging after any renewed +# silence, and clear the assignment (moving the issue back to a To-Do column by +# Type) if a check-in goes unanswered. The "guild hold" label pauses the sweep. + +name: Guild Stale Assignments + +on: + schedule: + - cron: "0 8 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: guild-stale-assignments + cancel-in-progress: true + +jobs: + sweep: + if: github.repository == 'zed-industries/zed' + runs-on: namespace-profile-2x4-ubuntu-2404 + timeout-minutes: 15 + + steps: + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} + private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} + owner: zed-industries + + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + sparse-checkout: | + script/github-guild-board.py + sparse-checkout-cone-mode: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install requests + + - name: Sweep stale assignments + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + PROJECT_NUMBER: "74" + GUILD_MODE: stale + SLACK_WEBHOOK_GUILD_INTERNAL: ${{ secrets.SLACK_WEBHOOK_GUILD_INTERNAL }} + run: python script/github-guild-board.py diff --git a/.github/workflows/guild_weekly_shipped.yml b/.github/workflows/guild_weekly_shipped.yml new file mode 100644 index 00000000000..9f2a7fb4c8c --- /dev/null +++ b/.github/workflows/guild_weekly_shipped.yml @@ -0,0 +1,55 @@ +# Scheduled Slack digest of Guild board +# (https://github.com/orgs/zed-industries/projects/74) issues recently closed by +# a merged PR authored by a guild member. + +name: Guild Weekly Shipped + +on: + schedule: + - cron: "0 7 * * 3" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: guild-weekly-shipped + cancel-in-progress: true + +jobs: + digest: + if: github.repository == 'zed-industries/zed' + runs-on: namespace-profile-2x4-ubuntu-2404 + timeout-minutes: 15 + + steps: + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} + private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} + owner: zed-industries + + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + sparse-checkout: | + script/github-guild-board.py + sparse-checkout-cone-mode: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install requests + + - name: Build and send digest + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + PROJECT_NUMBER: "74" + GUILD_MODE: weekly + SLACK_WEBHOOK_GUILD_INTERNAL: ${{ secrets.SLACK_WEBHOOK_GUILD_INTERNAL }} + run: python script/github-guild-board.py diff --git a/.github/workflows/pr_issue_labeler.yml b/.github/workflows/pr_issue_labeler.yml index fbba166a244..f295dcb4f9a 100644 --- a/.github/workflows/pr_issue_labeler.yml +++ b/.github/workflows/pr_issue_labeler.yml @@ -41,47 +41,7 @@ jobs: const STAFF_TEAM_SLUG = 'staff'; const FIRST_CONTRIBUTION_LABEL = 'first contribution'; const GUILD_LABEL = 'guild'; - const GUILD_MEMBERS = [ - '11happy', - 'AidanV', - 'alanpjohn', - 'AmaanBilwar', - 'arjunkomath', - 'austincummings', - 'ayushk-1801', - 'criticic', - 'dongdong867', - 'emamulandalib', - 'eureka928', - 'feitreim', - 'iam-liam', - 'iksuddle', - 'ishaksebsib', - 'lingyaochu', - 'loadingalias', - 'marcocondrache', - 'mchisolm0', - 'MostlyKIGuess', - 'nairadithya', - 'nihalxkumar', - 'notJoon', - 'OmChillure', - 'Palanikannan1437', - 'polyesterswing', - 'prayanshchh', - 'razeghi71', - 'sarmadgulzar', - 'seanstrom', - 'Shivansh-25', - 'SkandaBhat', - 'th0jensen', - 'tommyming', - 'transitoryangel', - 'TwistingTwists', - 'virajbhartiya', - 'YEDASAVG', - 'Ziqi-Yang', - ]; + const GUILD_TEAM_SLUG = 'guild-cohort-2'; const COMMUNITY_CHAMPION_LABEL = 'community champion'; const COMMUNITY_CHAMPIONS = [ '0x2CA', @@ -161,11 +121,11 @@ jobs: return members.some((member) => member.toLowerCase() === authorLower); }; - const isStaffMember = async (author) => { + const isTeamMember = async (teamSlug, author) => { try { const response = await github.rest.teams.getMembershipForUserInOrg({ org: 'zed-industries', - team_slug: STAFF_TEAM_SLUG, + team_slug: teamSlug, username: author }); return response.data.state === 'active'; @@ -177,6 +137,9 @@ jobs: } }; + const isStaffMember = (author) => isTeamMember(STAFF_TEAM_SLUG, author); + const isGuildMember = (author) => isTeamMember(GUILD_TEAM_SLUG, author); + const getIssueLabels = () => { if (listIncludesAuthor(COMMUNITY_CHAMPIONS, author)) { return [COMMUNITY_CHAMPION_LABEL]; @@ -202,7 +165,7 @@ jobs: labelsToAdd.push(COMMUNITY_CHAMPION_LABEL); } - if (listIncludesAuthor(GUILD_MEMBERS, author)) { + if (await isGuildMember(author)) { labelsToAdd.push(GUILD_LABEL); } diff --git a/.github/workflows/slack_notify_community_automation_failure.yml b/.github/workflows/slack_notify_community_automation_failure.yml index 688cfdded83..ae58a67868d 100644 --- a/.github/workflows/slack_notify_community_automation_failure.yml +++ b/.github/workflows/slack_notify_community_automation_failure.yml @@ -12,6 +12,10 @@ on: - "Community PR Board" - "PR Board Meta Fields Refresh" - "PR Issue Labeler" + - "Guild Assignment Status" + - "Guild Stale Assignments" + - "Guild Weekly Shipped" + - "Guild New PR Notification" types: [completed] jobs: diff --git a/script/github-community-pr-board.py b/script/github-community-pr-board.py index a182de8ac65..92724e5722f 100644 --- a/script/github-community-pr-board.py +++ b/script/github-community-pr-board.py @@ -155,11 +155,17 @@ def compute_size_bucket(total_changes): def compute_contributor(pr_labels): """Return the Contributor value derived from the PR's labels. - The auto-labeler is responsible for applying `community champion` and - `first contribution` based on the author's history; anything else on - the board is treated as a returning contributor (PRs from staff/bots + The auto-labeler is responsible for applying `guild`, `community champion` + and `first contribution` based on the author's membership/history; anything + else on the board is treated as a returning contributor (PRs from staff/bots don't reach this function because they're filtered upstream). + + Guild takes precedence over Champion because current-cohort guild members + are frequently also community champions, and their guild status is the more + relevant signal while the cohort is active. """ + if "guild" in pr_labels: + return "Guild" if "community champion" in pr_labels: return "Champion" if "first contribution" in pr_labels: diff --git a/script/github-guild-board.py b/script/github-guild-board.py new file mode 100644 index 00000000000..1fe4c34baaf --- /dev/null +++ b/script/github-guild-board.py @@ -0,0 +1,634 @@ +#!/usr/bin/env python3 +""" +Automation for the Guild project board (#74). + +GUILD_MODE selects behavior: + +- event: react to a single issue webhook. + assigned guild member -> Status "In Progress", or a Slack heads-up if + the issue isn't on the board. + unassigned guild member off an "In Progress" issue -> move back to a + To-Do column by Type (e.g. Bugs go back to Bug Bashers column) + Slack. + created (issue_comment) guild assignee comments on an issue that has a + pending check-in -> Slack (fires on each such comment). +- stale: nudge issues assigned to a guild member with no linked PR once the + assignee goes quiet; a reply resets the clock, so nudges recur after renewed + silence, and the assignment is cleared after a further grace period without a + reply. The "guild hold" label pauses both nudging and clearing. +- weekly: Slack digest of board issues recently closed by a merged PR authored + by a guild member. + +Requires: requests +""" + +import html +import json +import os +import random +import time +from datetime import datetime, timedelta, timezone +from functools import lru_cache + +import requests + +RETRYABLE_STATUS_CODES = {502, 503, 504} +MAX_RETRIES = 3 +RETRY_DELAY_SECONDS = 5 + +GITHUB_API_URL = "https://api.github.com" +REPO_OWNER = "zed-industries" +REPO_NAME = "zed" +GUILD_TEAM_SLUG = "guild-cohort-2" + +STATUS_FIELD = "Status" +STATUS_IN_PROGRESS = "In Progress" +STATUS_BUG_BASHERS = "Bug Bashers" +STATUS_SHIP_FEATURE = "Ship a New Feature" + +# Which to-do column to move issues of a given type to. +STATUS_FOR_TYPE = { + "Feature": STATUS_SHIP_FEATURE, + "Bug": STATUS_BUG_BASHERS, + "Docs": STATUS_BUG_BASHERS, + "Crash": STATUS_BUG_BASHERS, +} + +# How many days to wait after assignment before posting a check-in comment. +CHECK_IN_AFTER_DAYS = 14 +# Clear the assignment after this many days of no reply to check-in. +AUTO_CLEAR_AFTER_DAYS = 7 +# For the slack summaries of what's been recently shipped by the Guild. +SHIPPED_WINDOW_DAYS = 7 + +# Applying this label to an issue pauses the stale sweep for it (no nudges, no +# auto-clear of the assignee) until a human removes the label. +NUDGE_HOLD_LABEL = "guild hold" + +# Hidden in the rendered issue; lets later runs find the bot's own check-in. +CHECK_IN_MARKER = "" + +CHECK_IN_BODY = ( + "{marker}\n" + "Hey @{assignee}, checking in to see if you're still actively working on " + "this issue. No worries if you no longer have the time. If that's the case, " + "do you mind unassigning yourself from the issue so another contributor can " + "work on it? If you are still working on it, drop a comment and let us know " + "how we can help! Otherwise, the bot will clear the assignment in " + "{clear_days} days from now." +) + +ZEDGAR_QUIPS = [ + "Deep into the backlog peering...", + "Once upon a board so dreary...", + "A tell-tale ping beneath the board.", +] + + +def github_graphql(query, variables): + for attempt in range(MAX_RETRIES + 1): + response = requests.post( + f"{GITHUB_API_URL}/graphql", + headers=GITHUB_HEADERS, + json={"query": query, "variables": variables}, + timeout=30, + ) + if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES: + time.sleep(RETRY_DELAY_SECONDS) + continue + response.raise_for_status() + result = response.json() + if "errors" in result: + raise RuntimeError(f"GraphQL error: {result['errors']}") + return result["data"] + raise RuntimeError("github_graphql: retry loop exited without return") + + +def github_rest_request(method, path, body=None): + url = f"{GITHUB_API_URL}/{path}" + for attempt in range(MAX_RETRIES + 1): + response = requests.request( + method, url, headers=GITHUB_HEADERS, json=body, timeout=30 + ) + if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES: + time.sleep(RETRY_DELAY_SECONDS) + continue + response.raise_for_status() + if response.status_code == 204 or not response.content: + return None + return response.json() + raise RuntimeError("github_rest_request: retry loop exited without return") + + +def github_rest_get_paginated(path): + results = [] + page = 1 + while True: + separator = "&" if "?" in path else "?" + batch = github_rest_request("GET", f"{path}{separator}per_page=100&page={page}") + if not batch: + break + results.extend(batch) + if len(batch) < 100: + break + page += 1 + return results + + +@lru_cache(maxsize=None) +def is_guild_member(username): + response = requests.get( + f"{GITHUB_API_URL}/orgs/{REPO_OWNER}/teams/{GUILD_TEAM_SLUG}/memberships/{username}", + headers=GITHUB_HEADERS, + timeout=30, + ) + if response.status_code == 404: + return False + response.raise_for_status() + # A pending invitation reports state "pending"; only active members count. + return response.json().get("state") == "active" + + +def issue_comments(issue_number): + return github_rest_get_paginated( + f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments" + ) + + +def latest_assignment_time(issue_number, assignee): + events = github_rest_get_paginated( + f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/timeline" + ) + times = [ + parse_dt(event["created_at"]) + for event in events + if event.get("event") == "assigned" + and (event.get("assignee") or {}).get("login") == assignee + ] + if not times: + raise RuntimeError( + f"No assignment event for {assignee} on issue #{issue_number}" + ) + return max(times) + + +def issue_closing_prs(issue_node_id, include_closed_prs=False): + # Caps at the first 20 closing PRs: callers only test presence or scan for a + # single guild-authored merge, so an issue exceeding this bound is not worth + # paginating. + data = github_graphql( + """ + query($issueId: ID!, $includeClosedPrs: Boolean!) { + node(id: $issueId) { + ... on Issue { + closedByPullRequestsReferences(first: 20, includeClosedPrs: $includeClosedPrs) { + nodes { merged author { login } } + } + } + } + } + """, + {"issueId": issue_node_id, "includeClosedPrs": include_closed_prs}, + ) + node = data.get("node") or {} + refs = node.get("closedByPullRequestsReferences") or {} + return [ + {"merged": pr["merged"], "author": (pr.get("author") or {}).get("login")} + for pr in refs.get("nodes", []) + ] + + +def fetch_project(project_number): + data = github_graphql( + """ + query($owner: String!, $number: Int!) { + organization(login: $owner) { + projectV2(number: $number) { + id + fields(first: 50) { + nodes { + ... on ProjectV2Field { id name dataType } + ... on ProjectV2SingleSelectField { id name options { id name } } + } + } + } + } + } + """, + {"owner": REPO_OWNER, "number": project_number}, + ) + project = data["organization"]["projectV2"] + if not project: + raise RuntimeError(f"Project #{project_number} not found in {REPO_OWNER}") + return project + + +def find_project_item(project_id, content_node_id): + # Also fetches each item's single-select values, so callers that need the + # issue's Status (the unassignment handler) don't have to re-query the item. + data = github_graphql( + """ + query($contentId: ID!) { + node(id: $contentId) { + ... on Issue { + projectItems(first: 50) { + nodes { + id + project { id } + fieldValues(first: 20) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + field { ... on ProjectV2SingleSelectField { name } } + name + } + } + } + } + } + } + } + } + """, + {"contentId": content_node_id}, + ) + node = data.get("node") or {} + for item in (node.get("projectItems") or {}).get("nodes", []): + if item["project"]["id"] == project_id: + return item + return None + + +def set_project_field(project, item_id, field_name, option_name): + field = next( + (f for f in project["fields"]["nodes"] if f.get("name") == field_name), None + ) + if not field: + raise RuntimeError(f"Field '{field_name}' not found on board") + option_id = next( + (o["id"] for o in field.get("options", []) if o["name"] == option_name), None + ) + if not option_id: + raise RuntimeError(f"Option '{option_name}' not found in field '{field_name}'") + github_graphql( + """ + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, itemId: $itemId, fieldId: $fieldId, + value: { singleSelectOptionId: $optionId } + }) { projectV2Item { id } } + } + """, + { + "projectId": project["id"], + "itemId": item_id, + "fieldId": field["id"], + "optionId": option_id, + }, + ) + + +def list_project_issues(project_id): + cursor = None + while True: + data = github_graphql( + """ + query($projectId: ID!, $cursor: String) { + node(id: $projectId) { + ... on ProjectV2 { + items(first: 100, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + id + isArchived + fieldValues(first: 20) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + field { ... on ProjectV2SingleSelectField { name } } + name + } + } + } + content { + __typename + ... on Issue { + id number title url state closedAt + issueType { name } + assignees(first: 10) { nodes { login } } + labels(first: 20) { nodes { name } } + } + } + } + } + } + } + } + """, + {"projectId": project_id, "cursor": cursor}, + ) + page = data["node"]["items"] + yield from page["nodes"] + if not page["pageInfo"]["hasNextPage"]: + return + cursor = page["pageInfo"]["endCursor"] + + +def post_comment(issue_number, body): + github_rest_request( + "POST", f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments", {"body": body} + ) + + +def remove_assignees(issue_number, assignees): + github_rest_request( + "DELETE", + f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/assignees", + {"assignees": assignees}, + ) + + +def escape_slack(text): + # Escape Slack's control characters (&, <, >) so free text like issue/PR + # titles and comment bodies renders literally instead of being interpreted + # as a link or @-mention. send_slack can't do this wholesale because its own + # messages legitimately contain links and *bold* markup. + # quote=False keeps it to Slack's three characters (no " or ' escaping). + return html.escape(text or "", quote=False) + + +def slack_link(url, text): + # The single way to embed a titled link, so the title is always escaped + # without every caller having to remember to do it. + return f"<{url}|{escape_slack(text)}>" + + +def send_slack(text): + webhook = os.environ.get("SLACK_WEBHOOK_GUILD_INTERNAL") + if not webhook: + raise RuntimeError("SLACK_WEBHOOK_GUILD_INTERNAL is not set") + message = f"{random.choice(ZEDGAR_QUIPS)} {text}" + response = requests.post( + webhook, + json={ + "text": message, + "blocks": [{"type": "section", "text": {"type": "mrkdwn", "text": message}}], + }, + timeout=30, + ) + if response.status_code != 200: + raise RuntimeError( + f"Slack webhook returned {response.status_code}: {response.text[:200]}" + ) + + +def parse_dt(value): + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def item_status(item): + for field_value in (item.get("fieldValues") or {}).get("nodes", []): + if field_value.get("field", {}).get("name") == STATUS_FIELD: + return field_value.get("name") + return None + + +def latest_check_in_time(comments): + times = [ + parse_dt(comment["created_at"]) + for comment in comments + if CHECK_IN_MARKER in (comment.get("body") or "") + ] + return max(times, default=None) + + +def handle_assignment(issue, assignee_login, project_number): + if not is_guild_member(assignee_login): + return + + project = fetch_project(project_number) + item = find_project_item(project["id"], issue["node_id"]) + if not item: + send_slack( + f"Heads-up: @{assignee_login} was assigned to issue #{issue['number']} " + f"{slack_link(issue['html_url'], issue['title'])}, which isn't on the Guild board. " + "Possible mistake — if it's meant to be Guild work, add it to Bug Bashers " + "or Ship a New Feature; otherwise no action needed." + ) + return + + set_project_field(project, item["id"], STATUS_FIELD, STATUS_IN_PROGRESS) + + +def handle_unassignment(issue, removed_login, sender, project_number): + # Our own auto-clear also unassigns; don't double-handle that. + if (sender or {}).get("type") == "Bot": + return + if not is_guild_member(removed_login): + return + + project = fetch_project(project_number) + item = find_project_item(project["id"], issue["node_id"]) + if not item or item_status(item) != STATUS_IN_PROGRESS: + return + + sender_login = (sender or {}).get("login") + who = ( + "unassigned themselves from" + if sender_login == removed_login + else f"was unassigned (by @{sender_login}) from" + ) + status = STATUS_FOR_TYPE.get((issue.get("type") or {}).get("name") or "") + link = slack_link(issue["html_url"], issue["title"]) + if status: + set_project_field(project, item["id"], STATUS_FIELD, status) + send_slack( + f"@{removed_login} {who} In Progress issue #{issue['number']} " + f"{link}. I moved it back to *{status}*, " + "so it's up for grabs again — no action needed." + ) + else: + send_slack( + f"@{removed_login} {who} In Progress issue #{issue['number']} " + f"{link}. I couldn't tell its Type, so it's " + "still In Progress — please move it to Bug Bashers or Ship a New Feature so it " + "can be picked up again, then set the :done-checkmark: emoji on this message when done." + ) + + +def handle_comment(issue, comment): + commenter = (comment.get("user") or {}).get("login") + if not commenter or CHECK_IN_MARKER in (comment.get("body") or ""): + return + + assignees = [a["login"] for a in (issue.get("assignees") or [])] + if commenter not in assignees or not is_guild_member(commenter): + return + + check_in_at = latest_check_in_time(issue_comments(issue["number"])) + if check_in_at is None or parse_dt(comment["created_at"]) <= check_in_at: + return + + reply = " ".join((comment.get("body") or "").split()) + if len(reply) > 500: + reply = reply[:500] + "…" + send_slack( + f"@{commenter} replied to the check-in on In Progress issue #{issue['number']} " + f"{slack_link(issue['html_url'], issue['title'])}:\n" + f"> {escape_slack(reply)}\n" + f"Follow up if they need help, or add the `{NUDGE_HOLD_LABEL}` label to the " + "issue to pause check-ins on it." + ) + + +def run_event(project_number): + event_name = os.environ.get("GITHUB_EVENT_NAME", "") + with open(os.environ["GITHUB_EVENT_PATH"]) as f: + event = json.load(f) + + if event_name == "issues": + action = event["action"] + issue = event["issue"] + if action == "assigned": + handle_assignment(issue, event["assignee"]["login"], project_number) + elif action == "unassigned": + handle_unassignment( + issue, event["assignee"]["login"], event.get("sender"), project_number + ) + elif event_name == "issue_comment" and "pull_request" not in event["issue"]: + handle_comment(event["issue"], event["comment"]) + + +def run_stale(project_number): + project = fetch_project(project_number) + checked_in = cleared = 0 + + for item in list_project_issues(project["id"]): + if item.get("isArchived"): + continue + content = item.get("content") or {} + if content.get("__typename") != "Issue": + continue + if item_status(item) != STATUS_IN_PROGRESS: + continue + + assignees = [a["login"] for a in content["assignees"]["nodes"]] + guild_assignees = [a for a in assignees if is_guild_member(a)] + if not guild_assignees: + continue + + labels = [label["name"] for label in (content.get("labels") or {}).get("nodes", [])] + if NUDGE_HOLD_LABEL in labels: + continue + + issue_number = content["number"] + if issue_closing_prs(content["id"]): + continue + + comments = issue_comments(issue_number) + assignee = guild_assignees[0] + + # One pass over the comments for both the latest check-in and the + # assignee's latest comment. Measuring from the assignee's last activity + # (their assignment or a later comment) means a reply resets the clock, so + # a nudge only recurs after renewed silence (and a check-in from a prior + # assignment stint predates this, so it is naturally ignored). + last_activity = latest_assignment_time(issue_number, assignee) + check_in_at = None + for comment in comments: + created_at = parse_dt(comment["created_at"]) + if CHECK_IN_MARKER in (comment.get("body") or ""): + if check_in_at is None or created_at > check_in_at: + check_in_at = created_at + elif (comment.get("user") or {}).get("login") == assignee: + if created_at > last_activity: + last_activity = created_at + + if check_in_at is None or check_in_at < last_activity: + # No outstanding nudge since the assignee last engaged; nudge once + # they have stayed quiet for the check-in window. + if (NOW - last_activity).days < CHECK_IN_AFTER_DAYS: + continue + post_comment( + issue_number, + CHECK_IN_BODY.format( + marker=CHECK_IN_MARKER, + assignee=assignee, + clear_days=AUTO_CLEAR_AFTER_DAYS, + ), + ) + checked_in += 1 + continue + + # We nudged and the assignee stayed quiet; clear once the grace period + # lapses, otherwise leave them the rest of it. + if (NOW - check_in_at).days < AUTO_CLEAR_AFTER_DAYS: + continue + + remove_assignees(issue_number, guild_assignees) + status = STATUS_FOR_TYPE.get((content.get("issueType") or {}).get("name") or "") + link = slack_link(content["url"], content["title"]) + if status: + set_project_field(project, item["id"], STATUS_FIELD, status) + send_slack( + f"Issue #{issue_number} {link} went quiet with @{assignee} " + "(no linked PR, no reply to my check-in), so I cleared the assignment and " + f"moved it back to *{status}*. It's up for grabs again — no action needed." + ) + else: + send_slack( + f"Issue #{issue_number} {link} went quiet with @{assignee} " + "(no linked PR, no reply to my check-in), so I cleared the assignment. I " + "couldn't tell its Type — please move it to Bug Bashers or Ship a New " + "Feature so it can be picked up again, then set the :done-checkmark: emoji " + "on this message when done." + ) + cleared += 1 + + print(f"Stale sweep: {checked_in} checked in, {cleared} cleared") + + +def run_weekly(project_number): + project = fetch_project(project_number) + cutoff = NOW - timedelta(days=SHIPPED_WINDOW_DAYS) + shipped = [] + + for item in list_project_issues(project["id"]): + content = item.get("content") or {} + if content.get("__typename") != "Issue" or content.get("state") != "CLOSED": + continue + if parse_dt(content["closedAt"]) < cutoff: + continue + for pr in issue_closing_prs(content["id"], include_closed_prs=True): + if pr["merged"] and pr["author"] and is_guild_member(pr["author"]): + shipped.append((pr["author"], content)) + break + + if not shipped: + send_slack("A quiet week on the Guild board — nothing shipped. Onward.") + return + + lines = "\n".join( + f"• @{author} — {slack_link(content['url'], content['title'])} (#{content['number']})" + for author, content in shipped + ) + send_slack(f"Here's what the Guild shipped this week:\n{lines}") + + +if __name__ == "__main__": + GITHUB_HEADERS = { + "Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + NOW = datetime.now(timezone.utc) + + project_number = int(os.environ["PROJECT_NUMBER"]) + mode = os.environ["GUILD_MODE"] + + if mode == "event": + run_event(project_number) + elif mode == "stale": + run_stale(project_number) + elif mode == "weekly": + run_weekly(project_number) + else: + raise RuntimeError(f"Unknown GUILD_MODE: {mode}") From 9375695626f1bba18ae5ba3153aa200c999e342e Mon Sep 17 00:00:00 2001 From: mertkanakkoc <52167810+mertkanakkoc@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:46:33 +0300 Subject: [PATCH 017/197] Project/use line hint for search (#58871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable This PR makes two changes: 1. detect() now returns the line number of the first match instead of just a boolean. 2. This line number (line_hint) is threaded through the pipeline and passed as a subrange to query.search in full buffer scan, so the full buffer scan starts from where the first match was already found instead of the beginning of the file. ## Scope - This optimization only applies to **local search** (not remote). - It is only effective for **single-line text and single-line regex** queries. For multiline queries, `detect()` returns `line_hint = 0`, so the full buffer is scanned from the beginning as before. - It does not apply to already-open buffers, which always receive `line_hint = 0`. ## Test i tried to search the word "typeably" in the file "pkgs/development/haskell-modules/hackage-packages.nix" of the repo [nixpkgs](https://github.com/NixOS/nixpkgs) given in the comment: [comment](https://github.com/zed-industries/zed/issues/38799#issuecomment-4088491295). Result seems promising: **with line_hint: search: 45.21ms** **without line_hint: search: 508.05ms** I measured the time within the function: "handle_find_all_matches" ## Assumption The core assumption is: if the search query is long enough (after 4 or 5 chars), the probability of finding multiple matches in a single file is low. In that case, re-scanning the content before line_hint is unnecessary — detect() function already confirmed there is no match there. Changes maybe can make an improvement on issues #38799 and #58905 --------- Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> --- crates/project/src/project.rs | 2 +- crates/project/src/project_search.rs | 133 ++++++++++++------- crates/project/src/search.rs | 34 +++-- crates/remote_server/src/headless_project.rs | 2 +- 4 files changed, 108 insertions(+), 63 deletions(-) diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 90b4a6a5997..12a3a263d2d 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -5681,7 +5681,7 @@ impl Project { } }); - while let Some(buffer) = new_matches.next().await { + while let Some((buffer, _)) = new_matches.next().await { let buffer_id = this.update(cx, |this, cx| { this.create_buffer_for_peer(&buffer, peer_id, cx).to_proto() }); diff --git a/crates/project/src/project_search.rs b/crates/project/src/project_search.rs index 600e7aa5932..971581dcf1a 100644 --- a/crates/project/src/project_search.rs +++ b/crates/project/src/project_search.rs @@ -16,7 +16,7 @@ use fs::Fs; use futures::FutureExt as _; use futures::{SinkExt, StreamExt, select_biased, stream::FuturesOrdered}; use gpui::{App, AppContext, AsyncApp, BackgroundExecutor, Entity, Priority, Task}; -use language::{Buffer, BufferSnapshot}; +use language::{Buffer, BufferSnapshot, Point}; use parking_lot::Mutex; use postage::oneshot; use rpc::{AnyProtoClient, proto}; @@ -27,7 +27,7 @@ use worktree::{Entry, ProjectEntryId, Snapshot, Worktree, WorktreeSettings}; use crate::{ Project, ProjectItem, ProjectPath, RemotelyCreatedModels, buffer_store::BufferStore, - search::{SearchQuery, SearchResult}, + search::{LineHint, SearchQuery, SearchResult}, worktree_store::WorktreeStore, }; @@ -61,7 +61,7 @@ enum SearchKind { #[must_use] pub struct SearchResultsHandle { results: Receiver, - matching_buffers: Receiver>, + matching_buffers: Receiver<(Entity, LineHint)>, trigger_search: Box Task<()> + Send + Sync>, } @@ -76,7 +76,7 @@ impl SearchResultsHandle { rx: self.results, } } - pub fn matching_buffers(self, cx: &mut App) -> SearchResults> { + pub fn matching_buffers(self, cx: &mut App) -> SearchResults<(Entity, LineHint)> { SearchResults { task_handle: (self.trigger_search)(cx), rx: self.matching_buffers, @@ -179,12 +179,15 @@ impl Search { let open_buffers = Arc::new(open_buffers); let executor = cx.background_executor().clone(); let (tx, rx) = unbounded(); - let (grab_buffer_snapshot_tx, grab_buffer_snapshot_rx) = unbounded(); + let (grab_buffer_snapshot_tx, grab_buffer_snapshot_rx) = + unbounded::<(Entity, LineHint)>(); let matching_buffers = grab_buffer_snapshot_rx.clone(); let trigger_search = Box::new(move |cx: &mut App| { cx.spawn(async move |cx| { for buffer in unnamed_buffers { - _ = grab_buffer_snapshot_tx.send(buffer).await; + _ = grab_buffer_snapshot_tx + .send((buffer, LineHint::default())) + .await; } let (find_all_matches_tx, find_all_matches_rx) = @@ -196,7 +199,10 @@ impl Search { let fill_requests = cx .background_spawn(async move { for buffer in open_buffers { - if let Err(_) = grab_buffer_snapshot_tx.send(buffer).await { + if let Err(_) = grab_buffer_snapshot_tx + .send((buffer, LineHint::default())) + .await + { return; } } @@ -304,8 +310,9 @@ impl Search { let forward_buffers = cx.background_spawn(async move { while let Ok(buffer) = buffer_rx.recv().await { - let _ = - grab_buffer_snapshot_tx.send(buffer.await?).await; + let _ = grab_buffer_snapshot_tx + .send((buffer.await?, LineHint::default())) + .await; } anyhow::Ok(()) }); @@ -377,7 +384,6 @@ impl Search { ) } else { drop(find_all_matches_tx); - None }; let ensure_matches_are_reported_in_order = if should_find_all_matches { @@ -387,6 +393,7 @@ impl Search { ) } else { drop(tx); + None }; @@ -412,7 +419,7 @@ impl Search { worktrees: Vec>, query: Arc, tx: Sender, - results: Sender>, + results: Sender>, results_tx: Sender, ) -> impl AsyncFnOnce(&mut AsyncApp) { async move |cx| { @@ -495,18 +502,22 @@ impl Search { } async fn maintain_sorted_search_results( - rx: Receiver>, - paths_for_full_scan: Sender, + rx: Receiver>, + paths_for_full_scan: Sender<(ProjectPath, LineHint)>, limit: usize, ) { let mut rx = pin!(rx); let mut matched = 0; while let Some(mut next_path_result) = rx.next().await { - let Some(successful_path) = next_path_result.next().await else { + let Some((successful_path, line_hint)) = next_path_result.next().await else { // This file did not produce a match, hence skip it. continue; }; - if paths_for_full_scan.send(successful_path).await.is_err() { + if paths_for_full_scan + .send((successful_path, line_hint)) + .await + .is_err() + { return; }; matched += 1; @@ -519,23 +530,26 @@ impl Search { /// Background workers cannot open buffers by themselves, hence main thread will do it on their behalf. async fn open_buffers( buffer_store: Entity, - rx: Receiver, - find_all_matches_tx: Sender>, + rx: Receiver<(ProjectPath, LineHint)>, + find_all_matches_tx: Sender<(Entity, LineHint)>, mut cx: AsyncApp, ) { let mut rx = pin!(rx.ready_chunks(64)); _ = maybe!(async move { while let Some(requested_paths) = rx.next().await { + let line_hints: Vec = + requested_paths.iter().map(|(_, line)| *line).collect(); let mut buffers = buffer_store.update(&mut cx, |this, cx| { requested_paths .into_iter() - .map(|path| this.open_buffer(path, cx)) + .map(|(path, _)| this.open_buffer(path, cx)) .collect::>() }); - + let mut line_hints = line_hints.into_iter(); while let Some(buffer) = buffers.next().await { + let line_hint = line_hints.next().unwrap_or(LineHint::default()); if let Some(buffer) = buffer.log_err() { - find_all_matches_tx.send(buffer).await?; + find_all_matches_tx.send((buffer, line_hint)).await?; } } } @@ -545,20 +559,23 @@ impl Search { } async fn grab_buffer_snapshots( - rx: Receiver>, - find_all_matches_tx: Sender<( - Entity, - BufferSnapshot, - oneshot::Sender<(Entity, Vec>)>, - )>, + rx: Receiver<(Entity, LineHint)>, + find_all_matches_tx: Sender, results: Sender, Vec>)>>, mut cx: AsyncApp, ) { _ = maybe!(async move { - while let Ok(buffer) = rx.recv().await { + while let Ok((buffer, line_hint)) = rx.recv().await { let snapshot = buffer.read_with(&mut cx, |this, _| this.snapshot()); let (tx, rx) = oneshot::channel(); - find_all_matches_tx.send((buffer, snapshot, tx)).await?; + find_all_matches_tx + .send(FindAllMatchesRequest { + buffer, + snapshot, + line_hint, + report_matches: tx, + }) + .await?; results.send(rx).await?; } debug_assert!(rx.is_empty()); @@ -645,11 +662,7 @@ struct Worker { candidates: FindSearchCandidates, /// Ok, we're back in background: run full scan & find all matches in a given buffer snapshot. /// Then, when you're done, share them via the channel you were given. - find_all_matches_rx: Receiver<( - Entity, - BufferSnapshot, - oneshot::Sender<(Entity, Vec>)>, - )>, + find_all_matches_rx: Receiver, } impl Worker { @@ -730,20 +743,28 @@ struct RequestHandler<'worker> { } impl RequestHandler<'_> { - async fn handle_find_all_matches( - &self, - (buffer, snapshot, mut report_matches): ( - Entity, - BufferSnapshot, - oneshot::Sender<(Entity, Vec>)>, - ), - ) { + async fn handle_find_all_matches(&self, request: FindAllMatchesRequest) { + let FindAllMatchesRequest { + buffer, + snapshot, + line_hint, + mut report_matches, + } = request; + let range_offset = if line_hint > 0 { + snapshot.point_to_offset(Point::new(line_hint, 0)) + } else { + 0 + }; + let subrange = (range_offset > 0).then(|| range_offset..snapshot.len()); let ranges = self .query - .search(&snapshot, None) + .search(&snapshot, subrange) .await .iter() - .map(|range| snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end)) + .map(|range| { + snapshot.anchor_before(range.start + range_offset) + ..snapshot.anchor_after(range.end + range_offset) + }) .collect::>(); _ = report_matches.send((buffer, ranges)).await; @@ -777,9 +798,9 @@ impl RequestHandler<'_> { return Ok(()); } - if self.query.detect(file).await.unwrap_or(false) { + if let Some(line_hint) = self.query.detect(file).await.ok().flatten() { // Yes, we should scan the whole file. - entry.should_scan_tx.send(entry.path).await?; + entry.should_scan_tx.send((entry.path, line_hint)).await?; } Ok(()) } @@ -816,10 +837,13 @@ impl RequestHandler<'_> { // The buffer is already in memory and that's the version we want to scan; // hence skip the dilly-dally and look for all matches straight away. should_scan_tx - .send(ProjectPath { - worktree_id: snapshot.id(), - path: entry.path.clone(), - }) + .send(( + ProjectPath { + worktree_id: snapshot.id(), + path: entry.path.clone(), + }, + LineHint::default(), + )) .await?; } else { self.confirm_contents_will_match_tx @@ -843,13 +867,20 @@ impl RequestHandler<'_> { struct InputPath { entry: Entry, snapshot: Snapshot, - should_scan_tx: oneshot::Sender, + should_scan_tx: oneshot::Sender<(ProjectPath, LineHint)>, } struct MatchingEntry { worktree_root: Arc, path: ProjectPath, - should_scan_tx: oneshot::Sender, + should_scan_tx: oneshot::Sender<(ProjectPath, LineHint)>, +} + +struct FindAllMatchesRequest { + buffer: Entity, + snapshot: BufferSnapshot, + line_hint: LineHint, + report_matches: oneshot::Sender<(Entity, Vec>)>, } /// This struct encapsulates the logic to decide whether a given gitignored directory should be diff --git a/crates/project/src/search.rs b/crates/project/src/search.rs index 316bb673a9a..c2804f853a3 100644 --- a/crates/project/src/search.rs +++ b/crates/project/src/search.rs @@ -1,5 +1,5 @@ use aho_corasick::{AhoCorasick, AhoCorasickBuilder}; -use anyhow::Result; +use anyhow::{Ok, Result}; use client::proto; use fancy_regex::{Captures, Regex, RegexBuilder}; use gpui::Entity; @@ -45,6 +45,8 @@ pub struct SearchInputs { buffers: Option>>, } +pub type LineHint = u32; + impl SearchInputs { pub fn as_str(&self) -> &str { self.query.as_ref() @@ -390,10 +392,10 @@ impl SearchQuery { pub(crate) async fn detect( &self, mut reader: BufReader>, - ) -> Result { + ) -> Result> { let query_str = self.as_str(); if query_str.is_empty() { - return Ok(false); + return Ok(None); } // Yield from this function every 20KB scanned. @@ -405,12 +407,17 @@ impl SearchQuery { if query_str.contains('\n') { reader.read_to_string(&mut text)?; text::LineEnding::normalize(&mut text); - Ok(search.is_match(&text)) + if search.is_match(&text) { + Ok(Some(LineHint::default())) + } else { + Ok(None) + } } else { let mut bytes_read = 0; + let mut line_number: LineHint = LineHint::default(); while reader.read_line(&mut text)? > 0 { if search.is_match(&text) { - return Ok(true); + return Ok(Some(line_number)); } bytes_read += text.len(); if bytes_read >= YIELD_THRESHOLD { @@ -418,8 +425,9 @@ impl SearchQuery { smol::future::yield_now().await; } text.clear(); + line_number += 1; } - Ok(false) + Ok(None) } } Self::Regex { @@ -429,12 +437,17 @@ impl SearchQuery { if *multiline { reader.read_to_string(&mut text)?; text::LineEnding::normalize(&mut text); - Ok(regex.is_match(&text)?) + if regex.is_match(&text)? { + Ok(Some(LineHint::default())) + } else { + Ok(None) + } } else { let mut bytes_read = 0; + let mut line_number: LineHint = LineHint::default(); while reader.read_line(&mut text)? > 0 { if regex.is_match(&text)? { - return Ok(true); + return Ok(Some(line_number)); } bytes_read += text.len(); if bytes_read >= YIELD_THRESHOLD { @@ -442,8 +455,9 @@ impl SearchQuery { smol::future::yield_now().await; } text.clear(); + line_number += 1; } - Ok(false) + Ok(None) } } } @@ -556,7 +570,7 @@ impl SearchQuery { yield_now().await; } - if let Ok(mat) = mat { + if let std::result::Result::Ok(mat) = mat { matches.push(mat.start()..mat.end()); } } diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index c4897cd4d05..e5b2ef47dea 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -1107,7 +1107,7 @@ impl HeadlessProject { } }); - while let Some(buffer) = new_matches.next().await { + while let Some((buffer, _)) = new_matches.next().await { let _ = buffer_store .update(cx, |this, cx| { this.create_buffer_for_peer(&buffer, REMOTE_SERVER_PEER_ID, cx) From da2b62c917f5171eb0bf1bb1ec3168e5d6093b99 Mon Sep 17 00:00:00 2001 From: "./daniele" <47982731+danielepintore@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:49:52 +0200 Subject: [PATCH 018/197] Fix relative line number calculation when the first row is wrapped (#53759) fixes #53726 Compute the base delta from the first visible, non-filtered row instead of using rows.start. This avoids off-by-one numbering when rows.start is a wrapped continuation that gets filtered out. Ensure deleted lines still advance the relative counter even when they are not numbered. Add a test for the wrapped scrolling case. P.S. This is my first time using rust for a serious pull request and let's say I'm a little bit rusty with it. 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: - N/A or Added/Fixed/Improved ... --------- Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Co-authored-by: Smit Barmase --- crates/editor/src/editor.rs | 46 +++++++++++++++++++++++--------- crates/editor/src/element.rs | 51 ++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 13 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 70b4911e17c..dbc177e7a93 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -11626,30 +11626,50 @@ impl EditorSnapshot { current_selection_head: DisplayRow, count_wrapped_lines: bool, ) -> HashMap { - let initial_offset = - self.relative_line_delta(current_selection_head, rows.start, count_wrapped_lines); - - self.row_infos(rows.start) + let mut row_infos = self + .row_infos(rows.start) .take(rows.len()) .enumerate() - .map(|(i, row_info)| (DisplayRow(rows.start.0 + i as u32), row_info)) + .map(|(index, row_info)| (DisplayRow(rows.start.0 + index as u32), row_info)) .filter(|(_row, row_info)| { row_info.buffer_row.is_some() || (count_wrapped_lines && row_info.wrapped_buffer_row.is_some()) }) - .enumerate() - .filter_map(|(i, (row, row_info))| { + .peekable(); + + // We find the first row that actually passes the filter and calculate its + // delta independently. This ensures accuracy when scrolling, as the first + // visible row in `rows` might be a wrap part that is filtered out, which + // would otherwise offset the counter for subsequent lines if we used + // `rows.start` as the base for enumeration. + let Some((first_row, _)) = row_infos.peek() else { + return HashMap::default(); + }; + + let mut current_delta = + self.relative_line_delta(current_selection_head, *first_row, count_wrapped_lines); + + row_infos + .filter_map(|(row, row_info)| { + let is_deleted = row_info + .diff_status + .is_some_and(|status| status.is_deleted()); + + if !self.number_deleted_lines && is_deleted { + // Even if we don't number this line, it still counts as a unit + // of distance for the relative numbers of lines below it. + current_delta += 1; + return None; + } + // We want to ensure here that the current line has absolute // numbering, even if we are in a soft-wrapped line. With the // exception that if we are in a deleted line, we should number this // relative with 0, as otherwise it would have no line number at all - let relative_line_number = (initial_offset + i as i64).unsigned_abs() as u32; + let relative_line_number = current_delta.unsigned_abs() as u32; + current_delta += 1; - (relative_line_number != 0 - || row_info - .diff_status - .is_some_and(|status| status.is_deleted())) - .then_some((row, relative_line_number)) + (relative_line_number != 0 || is_deleted).then_some((row, relative_line_number)) }) .collect() } diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 0e4cb930dcb..80f6bf7fab9 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -11280,6 +11280,57 @@ mod tests { assert_eq!(relative_rows[&DisplayRow(5)], 2); } + #[gpui::test] + async fn test_relative_line_numbers_after_scrolling_wrapped_line(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let window = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("", cx); + Editor::new(EditorMode::full(), buffer, None, window, cx) + }); + + update_test_language_settings( + cx, + &|settings: &mut settings::AllLanguageSettingsContent| { + settings.defaults.soft_wrap = Some(language_settings::SoftWrap::Bounded); + settings.defaults.preferred_line_length = Some(10); + }, + ); + + window + .update(cx, |editor, _window, cx| { + let text = format!("{}\nshort line", "a".repeat(100)); + editor.buffer.update(cx, |buffer, cx| { + buffer.edit([(Point::default()..Point::default(), text)], None, cx); + }); + }) + .unwrap(); + cx.run_until_parked(); + + window + .update(cx, |editor, _window, cx| { + let snapshot = editor.snapshot(_window, cx); + + let line_1_display_row = Point::new(1, 0).to_display_point(&snapshot).row(); + assert!( + line_1_display_row.0 > 1, + "Line 0 should wrap into multiple rows" + ); + + let start_row = DisplayRow(1); + let relative_rows = snapshot.calculate_relative_line_numbers( + &(start_row..line_1_display_row.next_row()), + line_1_display_row, + false, + ); + + // If the bug exists, line_1_display_row would have a non-zero relative number + // and would be included in the map. It should be 0 (and thus omitted). + assert!(!relative_rows.contains_key(&line_1_display_row)); + }) + .unwrap(); + } + #[gpui::test] async fn test_vim_visual_selections(cx: &mut TestAppContext) { init_test(cx, |_| {}); From e5513539ec4f2b9709c55060aa84fc4a536ee031 Mon Sep 17 00:00:00 2001 From: loadingalias <138315197+loadingalias@users.noreply.github.com> Date: Thu, 2 Jul 2026 06:16:01 -0400 Subject: [PATCH 019/197] editor: Accumulate consecutive KillRingCut line kills (#51761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #22490 ## Summary • Keep kill-ring state as a dedicated global structure (`text`, `metadata`, `row`, `column`, `buffer_id`) instead of a raw clipboard wrapper. • Update `editor::KillRingCut` to append consecutive empty-line kills only when all are true: - one selection only - previous selection was empty - same row/column - same buffer • Preserve existing behavior for non-empty and multi-selection cuts by replacing the ring payload instead of appending. • Preserve yank metadata behavior through the existing clipboard selection metadata path. • Add regression coverage in `editor_tests::test_kill_ring_cut_accumulates_multi_line_kills`. ## Verification • `cargo fmt --all -- --check` • `./script/check-keymaps` • `./script/clippy -p editor` • `cargo test -p editor -- --nocapture` • `cargo test -p editor editor_tests::test_kill_ring_cut_accumulates_multi_line_kills -- --exact` • `cargo test -p editor test_cut_line_ends -- --exact` ## Manual • `Ctrl+K`, `Ctrl+K`, `Ctrl+Y` with repeated line-end kills at same caret location pastes concatenated text. • Non-empty selection cut and multi-selection still replace as before (no unintended append). • Cut in one buffer then cut in another does not cross-append. • Moving caret between kills does not append. • Undo/redo around cut+yank path works cleanly. Release Notes: - Fixed Emacs-style kill-ring behavior so successive `KillRingCut` operations at the same caret context are accumulated and pasted together via one `KillRingYank`, while preserving existing non-empty selection and multi-selection semantics. --------- Co-authored-by: Christopher Biscardi --- crates/editor/src/clipboard.rs | 112 +++++++++++++++++++++++++++--- crates/editor/src/editor_tests.rs | 104 +++++++++++++++++++++++++++ 2 files changed, 206 insertions(+), 10 deletions(-) diff --git a/crates/editor/src/clipboard.rs b/crates/editor/src/clipboard.rs index 2d3afdac12b..90818dbe313 100644 --- a/crates/editor/src/clipboard.rs +++ b/crates/editor/src/clipboard.rs @@ -354,6 +354,22 @@ impl Editor { if self.read_only(cx) { return; } + let selection_count = self.selections.count(); + let first_selection = self.selections.first_anchor(); + let snapshot = self.buffer.read(cx).snapshot(cx); + let first_selection_is_empty = first_selection.start == first_selection.end; + let selection_start_point = first_selection.start.to_point(&snapshot); + let selection_start_row = selection_start_point.row; + let selection_start_column = selection_start_point.column; + let Some((_, text_anchor)) = self + .buffer + .read(cx) + .text_anchor_for_position(first_selection.start, cx) + else { + return; + }; + let buffer_id = text_anchor.buffer_id; + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.move_with(&mut |snapshot, sel| { if sel.is_empty() { @@ -365,7 +381,56 @@ impl Editor { }); }); let item = self.cut_common(false, window, cx); - cx.set_global(KillRing(item)) + + let Some(item_text) = item.text() else { + return; + }; + + let entry_metadata = item.entries().first().and_then(|entry| match entry { + ClipboardEntry::String(entry) => entry.metadata_json::>(), + _ => None, + }); + + let can_append = selection_count == 1 && first_selection_is_empty; + let item = if can_append + && let Some(previous_ring) = cx.try_global::() + && previous_ring.can_append + && previous_ring.buffer_id == buffer_id + && previous_ring.row == selection_start_row + && previous_ring.column == selection_start_column + { + let mut entries = previous_ring + .metadata + .as_ref() + .map_or_else(Vec::new, Clone::clone); + if let Some(metadata) = entry_metadata.as_ref() { + entries.extend_from_slice(metadata); + } + + let mut text = previous_ring.text.clone(); + text.push_str(&item_text); + let text_len = text.len(); + + KillRing { + text, + metadata: kill_ring_metadata_for_text(entries, text_len), + row: previous_ring.row, + column: previous_ring.column, + buffer_id: previous_ring.buffer_id, + can_append, + } + } else { + KillRing { + text: item_text, + metadata: entry_metadata, + row: selection_start_row, + column: selection_start_column, + buffer_id, + can_append, + } + }; + + cx.set_global(item) } pub(super) fn kill_ring_yank( @@ -374,15 +439,15 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - let (text, metadata) = if let Some(KillRing(item)) = cx.try_global() { - if let Some(ClipboardEntry::String(kill_ring)) = item.entries().first() { - (kill_ring.text().to_string(), kill_ring.metadata_json()) - } else { - return; - } - } else { + if !cx.has_global::() { return; - }; + } + + let (text, metadata) = cx.update_global::(|kill_ring, _| { + kill_ring.can_append = false; + (kill_ring.text.clone(), kill_ring.metadata.clone()) + }); + self.do_paste(&text, metadata, false, window, cx); } @@ -536,9 +601,36 @@ impl Editor { } } -struct KillRing(ClipboardItem); +struct KillRing { + text: String, + metadata: Option>, + row: u32, + column: u32, + buffer_id: BufferId, + can_append: bool, +} impl Global for KillRing {} +fn kill_ring_metadata_for_text( + mut metadata: Vec, + text_len: usize, +) -> Option> { + match metadata.len() { + 0 => None, + 1 => Some(metadata), + _ => { + let first_selection = metadata.remove(0); + Some(vec![ClipboardSelection { + len: text_len, + is_entire_line: false, + first_line_indent: first_selection.first_line_indent, + file_path: None, + line_range: None, + }]) + } + } +} + fn edit_for_markdown_paste<'a>( buffer: &MultiBufferSnapshot, range: Range, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index de1d6905205..dc50483f496 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -8992,6 +8992,110 @@ async fn test_cut_line_ends(cx: &mut TestAppContext) { } } +#[gpui::test] +async fn test_kill_ring_cut_accumulates_multi_line_kills(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("ˇone\ntwo"); + + cx.update_editor(|e, window, cx| { + e.kill_ring_cut(&KillRingCut, window, cx); + e.kill_ring_cut(&KillRingCut, window, cx); + }); + + cx.update_editor(|e, window, cx| e.kill_ring_yank(&KillRingYank, window, cx)); + cx.assert_editor_state("one\nˇtwo"); +} + +#[gpui::test] +async fn test_kill_ring_cut_matches_emacs_kill_line_sequence_at_end_of_buffer( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("ˇa\nb\nc"); + + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + + cx.update_editor(|editor, _, cx| { + assert_eq!(editor.text(cx), "\nc"); + }); + + cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx)); + cx.update_editor(|editor, _, cx| { + assert_eq!(editor.text(cx), "a\nb\nc"); + }); +} + +#[gpui::test] +async fn test_kill_ring_cut_accumulates_final_line_without_trailing_newline( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("ˇa\nb\nc"); + + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + + cx.update_editor(|editor, _, cx| { + assert_eq!(editor.text(cx), ""); + }); + + cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx)); + cx.update_editor(|editor, _, cx| { + assert_eq!(editor.text(cx), "a\nb\nc"); + }); +} + +#[gpui::test] +async fn test_kill_ring_yank_breaks_kill_ring_cut_accumulation(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("ˇa\nb\nc"); + + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx)); + cx.update_editor(|editor, window, cx| editor.move_to_beginning(&MoveToBeginning, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx)); + + cx.update_editor(|editor, _, cx| { + assert_eq!(editor.text(cx), "a\nb\nc"); + }); +} + +#[gpui::test] +async fn test_kill_ring_yank_pastes_accumulated_kill_at_each_cursor(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("ˇone\ntwo"); + + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + cx.update_editor(|editor, window, cx| editor.kill_ring_cut(&KillRingCut, window, cx)); + + cx.set_state("aˇ bˇ"); + cx.update_editor(|editor, window, cx| editor.kill_ring_yank(&KillRingYank, window, cx)); + cx.assert_editor_state("aone\nˇ bone\nˇ"); +} + #[gpui::test] async fn test_clipboard(cx: &mut TestAppContext) { init_test(cx, |_| {}); From 4a1cb2b1e24cf25463e29f7aa67980d4d86ebabc Mon Sep 17 00:00:00 2001 From: Ankan Misra Date: Thu, 2 Jul 2026 16:30:16 +0530 Subject: [PATCH 020/197] lsp_button: Fix missing server metadata after restart (#55162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restarting an LSP from the picker drops the server's metadata (version, memory, "View Logs") even though the new server is healthy. Reproduces with `vtsls`, `eslint`, `rust-analyzer`, and others; `vlsls` doesn't. The button kept id-keyed state in [`health_statuses`](https://github.com/zed-industries/zed/blob/main/crates/language_tools/src/lsp_button.rs#L159) and [`servers_per_buffer_abs_path`](https://github.com/zed-industries/zed/blob/main/crates/language_tools/src/lsp_button.rs#L161), but never handled [`LspStoreEvent::LanguageServerRemoved`](https://github.com/zed-industries/zed/blob/main/crates/project/src/lsp_store.rs#L4066) — there was even a [stale TODO](https://github.com/zed-industries/zed/blob/main/crates/language_tools/src/lsp_button.rs#L860) claiming the event wasn't emitted (it's emitted from [five sites](https://github.com/zed-industries/zed/blob/main/crates/project/src/lsp_store.rs#L11190) in `lsp_store`). On restart the dead id lingered next to the new one in both maps, and rendering picked the wrong entry. Fix: handle `LanguageServerRemoved` and evict the dead id from both maps. `binary_statuses` is left alone — it's name-keyed and shared across restart cycles to drive the "Downloading… → Starting…" UX. The cleanup is extracted to `LanguageServers::remove_server` so it's directly unit-testable. Added four tests covering health eviction, per-buffer eviction with empty-entry pruning, `binary_statuses` preservation, and the full restart sequence. [PR #50417](https://github.com/zed-industries/zed/pull/50417) attempted this earlier with a `LanguageServerAdded` handler. @SomeoneToIgnore flagged that as the wrong hook ([buffer registration](https://github.com/zed-industries/zed/blob/main/crates/language_tools/src/lsp_button.rs#L919-L948) is what populates state, not startup) and asked for tests. Handling `LanguageServerRemoved` is a cleaner fit — it's the natural counterpart to registration, and it also fixes the related case where stopping a server (without restart) leaks state. ## Verification Reproduced on `main` and verified the fix on this branch with `vtsls` and `eslint` against a small TypeScript project. | Before restart | After restart on `main` (bug) | After restart on this branch (fix) | |---|---|---| | vtsls row before restart — version, memory, View
Logs all visible | vtsls row after restart on main — metadata
gone | vtsls row after restart on fix branch —
metadata persists | 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 #53627 Release Notes: - Fixed missing language server metadata in the LSP menu after restart. --------- Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> --- crates/language_tools/src/lsp_button.rs | 180 +++++++++++++++++++++++- 1 file changed, 179 insertions(+), 1 deletion(-) diff --git a/crates/language_tools/src/lsp_button.rs b/crates/language_tools/src/lsp_button.rs index 22b2795145f..625e48b0877 100644 --- a/crates/language_tools/src/lsp_button.rs +++ b/crates/language_tools/src/lsp_button.rs @@ -724,6 +724,19 @@ impl LanguageServers { fn is_empty(&self) -> bool { self.binary_statuses.is_empty() && self.health_statuses.is_empty() } + + /// Drop all id-keyed state for a server that has been removed (stopped or + /// reaching end-of-life via restart). `binary_statuses` is intentionally + /// preserved — it is keyed by name and shared across restart cycles to + /// drive the "Downloading… → Starting…" status UX. + fn remove_server(&mut self, server_id: LanguageServerId) { + self.health_statuses.remove(&server_id); + self.servers_per_buffer_abs_path + .retain(|_, servers_for_path| { + servers_for_path.servers.remove(&server_id); + !servers_for_path.servers.is_empty() + }); + } } #[derive(Debug)] @@ -903,7 +916,6 @@ impl LspButton { let mut updated = false; // TODO `LspStore` is global and reports status from all language servers, even from the other windows. - // Also, we do not get "LSP removed" events so LSPs are never removed. match e { LspStoreEvent::LanguageServerUpdate { language_server_id, @@ -993,6 +1005,12 @@ impl LspButton { }); updated = true; } + LspStoreEvent::LanguageServerRemoved(server_id) => { + self.server_state.update(cx, |state, _| { + state.language_servers.remove_server(*server_id); + }); + updated = true; + } _ => {} }; @@ -1406,3 +1424,163 @@ impl Render for LspButton { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn server_id(n: usize) -> LanguageServerId { + LanguageServerId(n) + } + + fn server_name(s: &str) -> LanguageServerName { + LanguageServerName(s.into()) + } + + fn health_status(name: &str) -> LanguageServerHealthStatus { + LanguageServerHealthStatus { + name: server_name(name), + health: Some((None, ServerHealth::Ok)), + } + } + + fn servers_for_path(servers: &[(LanguageServerId, &str)]) -> ServersForPath { + ServersForPath { + servers: servers + .iter() + .map(|(id, name)| (*id, Some(server_name(name)))) + .collect(), + worktree: None, + } + } + + /// `remove_server` evicts the id from `health_statuses` so a restarted + /// server's new id renders without inheriting the old one's stale entry. + /// This is the regression test for #53627. + #[test] + fn remove_server_drops_health_entry_for_id() { + let mut state = LanguageServers::default(); + state + .health_statuses + .insert(server_id(1), health_status("rust-analyzer")); + state + .health_statuses + .insert(server_id(2), health_status("typescript-language-server")); + + state.remove_server(server_id(1)); + + assert!(!state.health_statuses.contains_key(&server_id(1))); + assert!(state.health_statuses.contains_key(&server_id(2))); + } + + /// `remove_server` evicts the id from each per-buffer entry; entries that + /// become empty are dropped so the map does not grow unbounded across + /// many buffer opens/closes. + #[test] + fn remove_server_evicts_id_from_per_buffer_entries_and_drops_empty_entries() { + let mut state = LanguageServers::default(); + let buffer_a = PathBuf::from("/project/a.rs"); + let buffer_b = PathBuf::from("/project/b.rs"); + + state.servers_per_buffer_abs_path.insert( + buffer_a.clone(), + servers_for_path(&[(server_id(1), "rust-analyzer")]), + ); + state.servers_per_buffer_abs_path.insert( + buffer_b.clone(), + servers_for_path(&[(server_id(1), "rust-analyzer"), (server_id(2), "typos-lsp")]), + ); + + state.remove_server(server_id(1)); + + assert!( + !state.servers_per_buffer_abs_path.contains_key(&buffer_a), + "buffer_a's entry held only the removed server, so the entry itself should be dropped", + ); + let buffer_b_entry = state + .servers_per_buffer_abs_path + .get(&buffer_b) + .expect("buffer_b's entry has another server, so it must be retained"); + assert!(!buffer_b_entry.servers.contains_key(&server_id(1))); + assert!(buffer_b_entry.servers.contains_key(&server_id(2))); + } + + /// `binary_statuses` is keyed by name and intentionally shared across + /// restart cycles to drive the "Downloading… → Starting…" UX. Removing a + /// single server's id must not touch it. + #[test] + fn remove_server_does_not_touch_binary_statuses() { + let mut state = LanguageServers::default(); + state.binary_statuses.insert( + server_name("rust-analyzer"), + LanguageServerBinaryStatus { + status: BinaryStatus::Starting, + message: None, + }, + ); + + state.remove_server(server_id(1)); + + assert!( + state + .binary_statuses + .contains_key(&server_name("rust-analyzer")), + "binary_statuses is name-keyed and shared across restart cycles", + ); + } + + /// Simulates the full restart event sequence: remove old id, register + /// new id with same name, write health for the new id. After restart + /// only the new id should be visible — no leftover entry from the old + /// incarnation. + #[test] + fn restart_sequence_leaves_only_new_server_id() { + let mut state = LanguageServers::default(); + let buffer = PathBuf::from("/project/main.rs"); + let name = "rust-analyzer"; + + // Pre-restart: server v1 is registered for the buffer with health. + state + .servers_per_buffer_abs_path + .insert(buffer.clone(), servers_for_path(&[(server_id(1), name)])); + state + .health_statuses + .insert(server_id(1), health_status(name)); + + // Restart: old id is removed. + state.remove_server(server_id(1)); + + // New id registers for the same buffer. + let entry = state + .servers_per_buffer_abs_path + .entry(buffer.clone()) + .or_insert_with(|| ServersForPath { + servers: HashMap::default(), + worktree: None, + }); + entry.servers.insert(server_id(2), Some(server_name(name))); + + // Health update for the new id arrives. + state + .health_statuses + .insert(server_id(2), health_status(name)); + + let entry = state + .servers_per_buffer_abs_path + .get(&buffer) + .expect("buffer must still be tracked"); + assert_eq!( + entry.servers.keys().copied().collect::>(), + vec![server_id(2)], + "exactly one server for this buffer — the new incarnation", + ); + assert!( + !state.health_statuses.contains_key(&server_id(1)), + "the dead server's health entry must not linger", + ); + assert!( + state.health_statuses.contains_key(&server_id(2)), + "the new server's health entry is present", + ); + } +} From 21d66eb9d593dafdf9d878ddeae0e7fcd08549df Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 2 Jul 2026 13:45:37 +0200 Subject: [PATCH 021/197] settings: Remove unused `message_editor` setting (#60260) Used this at one point when we still had chat in Collab, but now it's unused. Release Notes: - N/A --- assets/settings/default.json | 5 ---- crates/settings/src/vscode_import.rs | 1 - .../settings_content/src/settings_content.rs | 13 ---------- crates/settings_ui/src/page_data.rs | 26 +------------------ 4 files changed, 1 insertion(+), 44 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 74c6252fe73..8725587ade6 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1025,11 +1025,6 @@ // Default: project_diff "entry_primary_click_action": "project_diff", }, - "message_editor": { - // Whether to automatically replace emoji shortcodes with emoji characters. - // For example: typing `:wave:` gets replaced with `👋`. - "auto_replace_emoji_shortcode": true, - }, "agent": { // Whether the inline assistant should use streaming tools, when available "inline_assistant_use_streaming_tools": true, diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 75b953e7cbc..f7ad53118d3 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -199,7 +199,6 @@ impl VsCodeSettings { language_models: None, line_indicator_format: None, log: None, - message_editor: None, node: self.node_binary_settings(), outline_panel: self.outline_panel_settings_content(), diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index a1d1a433815..f0f652d2baa 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -211,9 +211,6 @@ pub struct SettingsContent { pub project_panel: Option, - /// Configuration for the Message Editor - pub message_editor: Option, - /// Configuration for Node-related features pub node: Option, @@ -839,16 +836,6 @@ pub struct PanelSettingsContent { pub default_width: Option, } -#[with_fallible_options] -#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, PartialEq)] -pub struct MessageEditorSettings { - /// Whether to automatically replace emoji shortcodes with emoji characters. - /// For example: typing `:wave:` gets replaced with `👋`. - /// - /// Default: false - pub auto_replace_emoji_shortcode: Option, -} - #[with_fallible_options] #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, PartialEq)] pub struct FileFinderSettingsContent { diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 34ee4440729..94dff0038f0 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -9791,7 +9791,7 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { ] } - fn global_only_miscellaneous_sub_section() -> [SettingsPageItem; 4] { + fn global_only_miscellaneous_sub_section() -> [SettingsPageItem; 3] { [ SettingsPageItem::SettingItem(SettingItem { title: "Image Viewer", @@ -9871,30 +9871,6 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { }], ], }), - SettingsPageItem::SettingItem(SettingItem { - title: "Auto Replace Emoji Shortcode", - description: "Whether to automatically replace emoji shortcodes with emoji characters.", - field: Box::new(SettingField { - organization_override: None, - json_path: Some("message_editor.auto_replace_emoji_shortcode"), - pick: |settings_content| { - settings_content - .message_editor - .as_ref() - .and_then(|message_editor| { - message_editor.auto_replace_emoji_shortcode.as_ref() - }) - }, - write: |settings_content, value, _| { - settings_content - .message_editor - .get_or_insert_default() - .auto_replace_emoji_shortcode = value; - }, - }), - metadata: None, - files: USER, - }), SettingsPageItem::SettingItem(SettingItem { title: "Drop Size Target", description: "Relative size of the drop target in the editor that will open dropped file as a split pane.", From c55693876ee2e8b3868a2287b22be1c1394058a3 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Thu, 2 Jul 2026 08:38:43 -0400 Subject: [PATCH 022/197] cloud_api_types: Remove unused `AcceptTermsOfServiceResponse` type (#60226) This PR removes the `AcceptTermsOfServiceResponse` struct, as it is no longer used. Release Notes: - N/A --- crates/cloud_api_types/src/cloud_api_types.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/cloud_api_types/src/cloud_api_types.rs b/crates/cloud_api_types/src/cloud_api_types.rs index a06e0268c96..0de0a5444d9 100644 --- a/crates/cloud_api_types/src/cloud_api_types.rs +++ b/crates/cloud_api_types/src/cloud_api_types.rs @@ -71,11 +71,6 @@ pub struct OrganizationEditPredictionConfiguration { pub is_feedback_enabled: bool, } -#[derive(Debug, PartialEq, Serialize, Deserialize)] -pub struct AcceptTermsOfServiceResponse { - pub user: AuthenticatedUser, -} - #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] pub struct LlmToken(pub String); From 52b61cf424b3d731994dcde1477374b5d8e49160 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 2 Jul 2026 14:56:04 +0200 Subject: [PATCH 023/197] zed: Respect `default_open_behavior` when opening file from Finder (#59661) Follow up to #59551 so that it also works when opening a single file, e.g. from finder See #59950 Release Notes: - N/A --- crates/zed/src/zed/open_listener.rs | 52 +++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index a992dfe4fd9..fd0705d5d94 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -750,9 +750,13 @@ pub(crate) fn open_options_for_request( location: &SerializedWorkspaceLocation, cx: &App, ) -> workspace::OpenOptions { - open_behavior.map_or_else(workspace::OpenOptions::default, |open_behavior| { - open_options_for_behavior(open_behavior, location, cx) - }) + let open_behavior = open_behavior.unwrap_or_else(|| { + match workspace::WorkspaceSettings::get_global(cx).default_open_behavior { + settings::DefaultOpenBehavior::ExistingWindow => cli::OpenBehavior::ExistingWindow, + settings::DefaultOpenBehavior::NewWindow => cli::OpenBehavior::PreferNewWindow, + } + }); + open_options_for_behavior(open_behavior, location, cx) } pub(crate) fn open_options_for_behavior( @@ -1364,6 +1368,48 @@ mod tests { assert!(options.requesting_window.is_none()); } + #[gpui::test] + fn test_open_options_for_request_respects_default_open_behavior(cx: &mut TestAppContext) { + use gpui::UpdateGlobal as _; + + let _app_state = init_test(cx); + + // A `None` behavior (e.g. a Finder or URL open) consults the UI-level + // `default_open_behavior` setting rather than falling back to fixed + // defaults. + cx.update(|cx| { + settings::SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.workspace.default_open_behavior = + Some(settings::DefaultOpenBehavior::NewWindow); + }); + }); + }); + let options = + cx.update(|cx| open_options_for_request(None, &SerializedWorkspaceLocation::Local, cx)); + assert_eq!( + options.workspace_matching, + workspace::WorkspaceMatching::MatchSubpaths + ); + assert!(!options.add_dirs_to_sidebar); + + cx.update(|cx| { + settings::SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.workspace.default_open_behavior = + Some(settings::DefaultOpenBehavior::ExistingWindow); + }); + }); + }); + let options = + cx.update(|cx| open_options_for_request(None, &SerializedWorkspaceLocation::Local, cx)); + assert_eq!( + options.workspace_matching, + workspace::WorkspaceMatching::MatchExact + ); + assert!(options.add_dirs_to_sidebar); + } + #[gpui::test] fn test_parse_agent_url(cx: &mut TestAppContext) { let _app_state = init_test(cx); From 442a3476bcaf8090e8e7ecaa4c0c1254236ff10f Mon Sep 17 00:00:00 2001 From: Marco Groot <60631182+marcogroot@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:01:57 +1000 Subject: [PATCH 024/197] Fix crash when trashing all untracked files (#60235) # Objective Fixes a crash when trashing a large number of untracked files from the Git Panel, caused by unbounded OS thread spawning (thread explosion). ## Solution `GitPanel::clean_all()` builds one deletion task per untracked file and then awaits them one by one. Since GPUI tasks start executing as soon as they are spawned, not when they are awaited, all deletions run concurrently. Each of them called `RealFs::trash`, which spawned a dedicated OS thread per call, so a large enough batch exceeded the OS per-process thread limit and panicked at the `.expect("The os can spawn threads")` in `RealFs::trash`. This PR replaces the dedicated thread with `smol::unblock`, which runs the blocking `trash::delete_with_info` call on a shared thread pool that is capped and queues additional work instead of spawning more. This is the same idiom already used elsewhere in the file (e.g. `atomic_write`) for blocking filesystem work. ## Testing - Did you test these changes? If so, how? yes, reproduced using steps in the issue before and after and confirmed fix works. https://github.com/user-attachments/assets/d3bcd049-485a-486f-877a-0e5c21b7917c Reproduction steps here: https://github.com/zed-industries/zed/issues/60216 ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #60216 --- Release Notes: - Fixed a crash caused by spawning one thread per file when trashing a large number of untracked files from the Git Panel. --------- Co-authored-by: dino --- crates/fs/src/fs.rs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 59fe449b185..746c45d9f82 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -807,15 +807,8 @@ impl Fs for RealFs { // its target and leave the link behind. let path = std::path::absolute(path).context("Could not make the path absolute")?; - let (tx, rx) = futures::channel::oneshot::channel(); - std::thread::Builder::new() - .name("trash file or dir".to_string()) - .spawn(|| tx.send(trash::delete_with_info(path))) - .expect("The os can spawn threads"); - - Ok(rx + Ok(smol::unblock(move || trash::delete_with_info(path)) .await - .context("Tx dropped or fs.restore panicked")? .context("Could not trash file or dir")? .into()) } From 2ec0e6c2d7024f684e8bc23ce5882419b81f7f11 Mon Sep 17 00:00:00 2001 From: Neel Date: Thu, 2 Jul 2026 14:07:34 +0100 Subject: [PATCH 025/197] editor: Fix panic when confirming completion across buffers (#59471) In multi-buffer context, `do_completion` resolved the completion's replace range anchors against whichever buffer currently held the newest selection. If that selection had moved into a different file's excerpt (while the menu remained open), the anchors belonged to a different buffer and caused a panic. This PR adds a guard to confirm the completion buffer and selection buffer is the same. Closes FR-76. --- Release Notes: - Fixed a panic that could occur when confirming a completion in a multibuffer if the cursor had moved into a different file --- crates/editor/src/completions.rs | 21 ++-- crates/editor/src/editor_tests.rs | 196 ++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+), 8 deletions(-) diff --git a/crates/editor/src/completions.rs b/crates/editor/src/completions.rs index e2f0be07ce0..8ad37dcfd63 100644 --- a/crates/editor/src/completions.rs +++ b/crates/editor/src/completions.rs @@ -830,14 +830,19 @@ impl Editor { 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 (lookbehind, lookahead) = if buffer.remote_id() == buffer_snapshot.remote_id() { + let lookbehind = newest_range_buffer + .start + .to_offset(&buffer) + .saturating_sub(replace_range.start.to_offset(&buffer)); + let lookahead = replace_range + .end + .to_offset(&buffer) + .saturating_sub(newest_range_buffer.end.to_offset(&buffer)); + (lookbehind, lookahead) + } else { + (0, 0) + }; let prefix = &old_text[..old_text.len().saturating_sub(lookahead)]; let suffix = &old_text[lookbehind.min(old_text.len())..]; diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index dc50483f496..145ceaa6943 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -17779,6 +17779,202 @@ async fn test_completion_in_multibuffer_with_replace_range(cx: &mut TestAppConte }) } +#[gpui::test] +async fn test_completion_in_multibuffer_with_newest_selection_in_other_buffer( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let main_text = indoc! {" + fn main() { + 10.satu + } + "}; + let other_text = indoc! {" + const VALUE: u32 = 0; + "}; + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": main_text, + "other.rs": other_text, + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + resolve_provider: None, + ..lsp::CompletionOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); + let main_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/a/main.rs"), cx) + }) + .await + .unwrap(); + let other_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/a/other.rs"), cx) + }) + .await + .unwrap(); + + let multi_buffer = cx.new(|cx| { + let mut multi_buffer = MultiBuffer::new(Capability::ReadWrite); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(0), + main_buffer.clone(), + [Point::zero()..main_buffer.read(cx).max_point()], + 0, + cx, + ); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(1), + other_buffer.clone(), + [Point::zero()..other_buffer.read(cx).max_point()], + 0, + cx, + ); + multi_buffer + }); + + let editor = workspace.update_in(cx, |_, window, cx| { + cx.new(|cx| { + Editor::new( + EditorMode::Full { + scale_ui_elements_with_buffer_font_size: false, + show_active_line_background: false, + sizing_behavior: SizingBehavior::Default, + }, + multi_buffer.clone(), + Some(project.clone()), + window, + cx, + ) + }) + }); + + let pane = workspace.update_in(cx, |workspace, _, _| workspace.active_pane().clone()); + pane.update_in(cx, |pane, window, cx| { + pane.add_item(Box::new(editor.clone()), true, true, None, window, cx); + }); + + let fake_server = fake_servers.next().await.unwrap(); + cx.run_until_parked(); + + // Place the (only, and therefore newest) selection in `main.rs`, right after `satu`. + let main_cursor = editor.update(cx, |editor, cx| { + let multibuffer_snapshot = editor.buffer().read(cx).snapshot(cx); + let main_snapshot = main_buffer.read(cx).snapshot(); + let anchor = main_snapshot.anchor_before(Point::new(1, 11)); + multibuffer_snapshot + .buffer_anchor_range_to_anchor_range(anchor..anchor) + .expect("main.rs cursor position should map into the multibuffer") + }); + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_anchor_ranges([main_cursor.clone()]); + }); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + + fake_server + .set_request_handler::(move |_, _| async move { + let completion_item = lsp::CompletionItem { + label: "saturating_sub()".into(), + text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( + lsp::InsertReplaceEdit { + new_text: "saturating_sub()".to_owned(), + insert: lsp::Range::new( + lsp::Position::new(1, 7), + lsp::Position::new(1, 11), + ), + replace: lsp::Range::new( + lsp::Position::new(1, 7), + lsp::Position::new(1, 11), + ), + }, + )), + ..lsp::CompletionItem::default() + }; + + Ok(Some(lsp::CompletionResponse::Array(vec![completion_item]))) + }) + .next() + .await + .unwrap(); + + cx.condition(&editor, |editor, _| editor.context_menu_visible()) + .await; + + // Move the newest selection into `other.rs` while keeping the completions menu + // open. + let other_cursor = editor.update(cx, |editor, cx| { + let multibuffer_snapshot = editor.buffer().read(cx).snapshot(cx); + let other_snapshot = other_buffer.read(cx).snapshot(); + let anchor = other_snapshot.anchor_before(Point::new(0, 6)); + multibuffer_snapshot + .buffer_anchor_range_to_anchor_range(anchor..anchor) + .expect("other.rs cursor position should map into the multibuffer") + }); + editor.update_in(cx, |editor, window, cx| { + editor.change_selections( + SelectionEffects::no_scroll().completions(false), + window, + cx, + |s| s.select_anchor_ranges([other_cursor.clone()]), + ); + assert!( + editor.context_menu_visible(), + "the completions menu should still be open after moving the cursor" + ); + }); + + // This used to panic with `invalid anchor - buffer id does not match`. + editor + .update_in(cx, |editor, window, cx| { + editor + .confirm_completion_replace(&ConfirmCompletionReplace, window, cx) + .unwrap() + }) + .await + .unwrap(); + + // The completion targets `main.rs`, so it should still be applied there. + main_buffer.read_with(cx, |buffer, _| { + assert_eq!( + buffer.text(), + indoc! {" + fn main() { + 10.saturating_sub() + } + "} + ); + }); +} + #[gpui::test] async fn test_completion(cx: &mut TestAppContext) { init_test(cx, |_| {}); From 969c6c719ca10102903590e15601c55b57a8838e Mon Sep 17 00:00:00 2001 From: Neel Date: Thu, 2 Jul 2026 14:07:42 +0100 Subject: [PATCH 026/197] editor: Fix panic when restoring empty selections on undo/redo (#59372) Closes FR-81. Also updates the error log, as the linked issue is closed. Release Notes: - Fixed panic when restoring empty selections on undo/redo --- crates/editor/src/editor.rs | 88 +++++++++++++++---------------- crates/editor/src/editor_tests.rs | 33 ++++++++++++ crates/vim/src/command.rs | 4 +- crates/vim/src/normal.rs | 3 +- 4 files changed, 80 insertions(+), 48 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index dbc177e7a93..36b5209a5c0 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1387,11 +1387,15 @@ struct DeferredSelectionEffectsState { history_entry: SelectionHistoryEntry, } +#[derive(Clone, Debug)] +pub struct TransactionSelections { + pub undo: Arc<[Selection]>, + pub redo: Option]>>, +} + #[derive(Default)] struct SelectionHistory { - #[allow(clippy::type_complexity)] - selections_by_transaction: - HashMap]>, Option]>>)>, + selections_by_transaction: HashMap, mode: SelectionHistoryMode, undo_stack: VecDeque, redo_stack: VecDeque, @@ -1411,23 +1415,23 @@ impl SelectionHistory { ); return; } - self.selections_by_transaction - .insert(transaction_id, (selections, None)); + self.selections_by_transaction.insert( + transaction_id, + TransactionSelections { + undo: selections, + redo: None, + }, + ); } - #[allow(clippy::type_complexity)] - fn transaction( - &self, - transaction_id: TransactionId, - ) -> Option<&(Arc<[Selection]>, Option]>>)> { + fn transaction(&self, transaction_id: TransactionId) -> Option<&TransactionSelections> { self.selections_by_transaction.get(&transaction_id) } - #[allow(clippy::type_complexity)] fn transaction_mut( &mut self, transaction_id: TransactionId, - ) -> Option<&mut (Arc<[Selection]>, Option]>>)> { + ) -> Option<&mut TransactionSelections> { self.selections_by_transaction.get_mut(&transaction_id) } @@ -7495,26 +7499,31 @@ impl Editor { }); } + fn restore_selections( + &mut self, + selections: Option]>>, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(selections) = selections.filter(|selections| !selections.is_empty()) { + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_anchors(selections.to_vec()); + }); + } + } + pub fn undo(&mut self, _: &Undo, window: &mut Window, cx: &mut Context) { if self.read_only(cx) { return; } if let Some(transaction_id) = self.buffer.update(cx, |buffer, cx| buffer.undo(cx)) { - if let Some((selections, _)) = - self.selection_history.transaction(transaction_id).cloned() - { - self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_anchors(selections.to_vec()); - }); - } else { - log::error!( - "No entry in selection_history found for undo. \ - This may correspond to a bug where undo does not update the selection. \ - If this is occurring, please add details to \ - https://github.com/zed-industries/zed/issues/22692" - ); + let transaction = self.selection_history.transaction(transaction_id); + if transaction.is_none() { + log::error!("No selection history for undone transaction; selection unchanged"); } + let selections = transaction.map(|transaction| transaction.undo.clone()); + self.restore_selections(selections, window, cx); self.request_autoscroll(Autoscroll::fit(), cx); self.unmark_text(window, cx); self.refresh_edit_prediction( @@ -7535,20 +7544,11 @@ impl Editor { } if let Some(transaction_id) = self.buffer.update(cx, |buffer, cx| buffer.redo(cx)) { - if let Some((_, Some(selections))) = - self.selection_history.transaction(transaction_id).cloned() - { - self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_anchors(selections.to_vec()); - }); - } else { - log::error!( - "No entry in selection_history found for redo. \ - This may correspond to a bug where undo does not update the selection. \ - If this is occurring, please add details to \ - https://github.com/zed-industries/zed/issues/22692" - ); - } + let selections = self + .selection_history + .transaction(transaction_id) + .and_then(|transaction| transaction.redo.clone()); + self.restore_selections(selections, window, cx); self.request_autoscroll(Autoscroll::fit(), cx); self.unmark_text(window, cx); self.refresh_edit_prediction( @@ -8061,7 +8061,7 @@ impl Editor { // will take you back to where you made the last edit, instead of staying where you scrolled self.selection_history .transaction(transaction_id_prev) - .map(|t| t.0.clone()) + .map(|t| t.undo.clone()) }) .unwrap_or_else(|| self.selections.disjoint_anchors_arc()); @@ -8287,10 +8287,8 @@ impl Editor { .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()); + if let Some(transaction) = self.selection_history.transaction_mut(transaction_id) { + transaction.redo = Some(self.selections.disjoint_anchors_arc()); } else { log::error!("unexpectedly ended a transaction that wasn't started by this editor"); } @@ -8305,7 +8303,7 @@ impl Editor { pub fn modify_transaction_selection_history( &mut self, transaction_id: TransactionId, - modify: impl FnOnce(&mut (Arc<[Selection]>, Option]>>)), + modify: impl FnOnce(&mut TransactionSelections), ) -> bool { self.selection_history .transaction_mut(transaction_id) diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 145ceaa6943..4acdae8d281 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -323,6 +323,39 @@ fn test_undo_redo_with_selection_restoration(cx: &mut TestAppContext) { }); } +#[gpui::test] +fn test_undo_redo_with_empty_history_selections_does_not_panic(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let now = Instant::now(); + let buffer = cx.new(|cx| language::Buffer::local("123456", cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let editor = cx.add_window(|window, cx| build_editor(buffer, window, cx)); + + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(0)..MultiBufferOffset(0)]) + }); + editor.start_transaction_at(now, window, cx); + editor.insert("a", window, cx); + let transaction_id = editor + .end_transaction_at(now, cx) + .expect("transaction should be created"); + assert_eq!(editor.text(cx), "a123456"); + + editor.modify_transaction_selection_history(transaction_id, |selections| { + selections.undo = Vec::new().into(); + selections.redo = Some(Vec::new().into()); + }); + + editor.undo(&Undo, window, cx); + assert_eq!(editor.text(cx), "123456"); + + editor.redo(&Redo, window, cx); + assert_eq!(editor.text(cx), "a123456"); + }); +} + #[gpui::test] fn test_accessibility_keyboard_word_completion(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 274fabac9a7..df6a911329b 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -835,8 +835,8 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { { let last_sel = editor.selections.disjoint_anchors_arc(); editor.modify_transaction_selection_history(tx_id, |old| { - old.0 = old.0.get(..1).unwrap_or(&[]).into(); - old.1 = Some(last_sel); + old.undo = old.undo.get(..1).unwrap_or(&[]).into(); + old.redo = Some(last_sel); }); } }); diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index d91af01a542..389eeb88e61 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -160,10 +160,11 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { let transaction_id = vim.visual_delete(false, window, cx); if let (Some(original_selections), Some(transaction_id)) = (original_selections, transaction_id) + && !original_selections.is_empty() { let updated = vim.update_editor(cx, |_, editor, _| { editor.modify_transaction_selection_history(transaction_id, |selections| { - selections.0 = original_selections; + selections.undo = original_selections; }) }); debug_assert_ne!(updated, Some(false)); From 7eb4cb2bfa02618761389d5e760a9a9b6763f301 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Thu, 2 Jul 2026 18:43:46 +0530 Subject: [PATCH 027/197] workspace: Fix panic when discarding a draft workspace (#60279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To reproduce: 1. Open a git project with the agent sidebar, ideally with no other threads in the sidebar. 2. Create a thread in a new git worktree and type into it without sending. 3. While still in that worktree workspace, hit New Thread. The parked draft must now be the last activatable entry in the sidebar. 4. Hover the parked draft → click Discard Draft 5. 💥 Release Notes: - Fixed a panic when discarding a draft workspace. --- crates/workspace/src/multi_workspace.rs | 4 +- crates/workspace/src/multi_workspace_tests.rs | 61 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 5fd304b7338..11461d38ee3 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -1228,7 +1228,9 @@ impl MultiWorkspace { window: &mut Window, cx: &mut Context, ) -> Task>> { - if let Some(workspace) = self.workspace_for_paths(&paths, host.as_ref(), cx) { + if let Some(workspace) = + self.workspace_for_paths_excluding(&paths, host.as_ref(), excluding, cx) + { self.activate(workspace.clone(), source_workspace, window, cx); return Task::ready(Ok(workspace)); } diff --git a/crates/workspace/src/multi_workspace_tests.rs b/crates/workspace/src/multi_workspace_tests.rs index 72c81503173..323fc10fb01 100644 --- a/crates/workspace/src/multi_workspace_tests.rs +++ b/crates/workspace/src/multi_workspace_tests.rs @@ -506,6 +506,67 @@ async fn test_find_or_create_workspace_uses_project_group_key_when_paths_are_mis }); } +#[gpui::test] +async fn test_remove_fallback_via_find_or_create_skips_removed_workspaces(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/root_a", json!({ "file.txt": "" })).await; + let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await; + let project_b = Project::test(fs, ["/root_a".as_ref()], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); + let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx) + }); + cx.run_until_parked(); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate(workspace_a.clone(), None, window, cx); + }); + + let removed = multi_workspace + .update_in(cx, |mw, window, cx| { + let excluded = vec![workspace_a.clone()]; + mw.remove( + excluded.clone(), + move |this, window, cx| { + this.find_or_create_workspace( + PathList::new(&[PathBuf::from("/root_a")]), + None, + None, + |_options, _window, _cx| Task::ready(Ok(None)), + &excluded, + None, + OpenMode::Activate, + window, + cx, + ) + }, + window, + cx, + ) + }) + .await + .expect("removing the active workspace should succeed"); + assert!(removed, "the workspace should have been removed"); + + multi_workspace.read_with(cx, |mw, _cx| { + assert_eq!( + mw.workspace().entity_id(), + workspace_b.entity_id(), + "the non-excluded workspace should become active" + ); + assert!( + mw.workspaces() + .all(|workspace| workspace.entity_id() != workspace_a.entity_id()), + "the removed workspace should be gone" + ); + }); +} + #[gpui::test] async fn test_find_or_create_local_workspace_reuses_active_workspace_after_sidebar_open( cx: &mut TestAppContext, From 78b6bf2fbe2aa46688507e839ac1b537522e4c58 Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Thu, 2 Jul 2026 21:23:17 +0800 Subject: [PATCH 028/197] command_palette: Show scrollbar in command palette (#60239) Enable the command palette picker scrollbar while preserving the one-shot reopen behavior. --- Release Notes: - N/A or Added/Fixed/Improved ... Signed-off-by: Xiaobo Liu --- crates/command_palette/src/command_palette.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 3b8a6e770e4..e1eddf01c28 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -126,7 +126,9 @@ impl CommandPalette { let picker = cx.new(|cx| { // One-shot action; there's nothing to reopen. - let picker = Picker::uniform_list(delegate, window, cx).reopenable(false, cx); + let picker = Picker::uniform_list(delegate, window, cx) + .reopenable(false, cx) + .show_scrollbar(true); picker.set_query(query, window, cx); picker }); From 17090674b34288db75128f96dfb336116e058ff2 Mon Sep 17 00:00:00 2001 From: ozacod <47009516+ozacod@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:49:41 +0300 Subject: [PATCH 029/197] text_finder: Add collapsible file groups (#60193) ## Summary Add a chevron (`Disclosure`) to each file group header in Text Finder, letting you fold/unfold that file's matches. Fold state is cleared whenever a new search starts. ## Test plan - [x] Open Text Finder, run a search with matches in multiple files - [x] Click a header's chevron, confirm its matches hide/show and selection lands on a visible row - [x] Start a new search, confirm folded state doesn't carry over Release Notes: - Added the ability to collapse per-file match groups in Text Finder --------- Co-authored-by: ozacod Co-authored-by: Danilo Leal --- assets/keymaps/specific-overrides-macos.json | 3 + assets/keymaps/specific-overrides.json | 3 + crates/search/src/text_finder.rs | 20 ++- crates/search/src/text_finder/delegate.rs | 173 ++++++++++++++++--- crates/search/src/text_finder/render.rs | 3 + 5 files changed, 177 insertions(+), 25 deletions(-) diff --git a/assets/keymaps/specific-overrides-macos.json b/assets/keymaps/specific-overrides-macos.json index 3fd1d6c87f8..e24dfb20517 100644 --- a/assets/keymaps/specific-overrides-macos.json +++ b/assets/keymaps/specific-overrides-macos.json @@ -39,6 +39,9 @@ "use_key_equivalents": true, "bindings": { "alt-cmd-f": "text_finder::ToProjectSearch", + "alt-cmd-[": "text_finder::Fold", + "alt-cmd-]": "text_finder::Unfold", + "cmd-shift-enter": "text_finder::ToggleFoldAll", "cmd-j": "pane::SplitDown", "cmd-k": "pane::SplitUp", "cmd-h": "pane::SplitLeft", diff --git a/assets/keymaps/specific-overrides.json b/assets/keymaps/specific-overrides.json index 663153a8435..2c4191089ab 100644 --- a/assets/keymaps/specific-overrides.json +++ b/assets/keymaps/specific-overrides.json @@ -39,6 +39,9 @@ "ctrl-k": "pane::SplitUp", "ctrl-h": "pane::SplitLeft", "ctrl-l": "pane::SplitRight", + "ctrl-{": "text_finder::Fold", + "ctrl-}": "text_finder::Unfold", + "ctrl-shift-enter": "text_finder::ToggleFoldAll", }, }, ] diff --git a/crates/search/src/text_finder.rs b/crates/search/src/text_finder.rs index 707587658ce..500f8e4fe9e 100644 --- a/crates/search/src/text_finder.rs +++ b/crates/search/src/text_finder.rs @@ -29,7 +29,7 @@ use util::ResultExt as _; use crate::{ProjectSearchView, SearchOptions, text_finder::delegate::PopulateProjectSearch}; -actions!(text_finder, [ToProjectSearch,]); +actions!(text_finder, [ToProjectSearch, Fold, Unfold, ToggleFoldAll]); pub struct TextFinder { picker: Entity>, @@ -259,6 +259,24 @@ impl TextFinder { self.open_in_split(workspace::SplitDirection::Down, window, cx); } + fn fold(&mut self, _: &Fold, _window: &mut Window, cx: &mut Context) { + self.picker.update(cx, |picker, cx| { + picker.delegate.set_selected_group_collapsed(true, cx); + }); + } + + fn unfold(&mut self, _: &Unfold, _window: &mut Window, cx: &mut Context) { + self.picker.update(cx, |picker, cx| { + picker.delegate.set_selected_group_collapsed(false, cx); + }); + } + + fn toggle_fold_all(&mut self, _: &ToggleFoldAll, _window: &mut Window, cx: &mut Context) { + self.picker.update(cx, |picker, cx| { + picker.delegate.toggle_all_collapsed(cx); + }); + } + fn open_in_split( &mut self, direction: workspace::SplitDirection, diff --git a/crates/search/src/text_finder/delegate.rs b/crates/search/src/text_finder/delegate.rs index 0b69e49d044..4d4b0717479 100644 --- a/crates/search/src/text_finder/delegate.rs +++ b/crates/search/src/text_finder/delegate.rs @@ -32,8 +32,8 @@ use editor::{MultiBufferSnapshot, PathKey, multibuffer_context_lines}; use file_icons::FileIcons; use futures::StreamExt; use gpui::{ - AnyElement, AppContext, AsyncApp, DismissEvent, EntityId, HighlightStyle, StyledText, Task, - TextStyle, prelude::*, + AnyElement, AppContext, AsyncApp, ClickEvent, DismissEvent, EntityId, HighlightStyle, + Modifiers, StyledText, Task, TextStyle, prelude::*, }; use gpui::{Entity, FocusHandle}; use language::{Buffer, LanguageAwareStyling}; @@ -45,15 +45,15 @@ use smol::future::yield_now; use text::Anchor; use theme_settings::ThemeSettings; use ui::{ - Divider, FluentBuilder, IconButtonShape, ListItem, ListItemSpacing, Toggleable, Tooltip, - prelude::*, + Disclosure, Divider, FluentBuilder, IconButtonShape, ListItem, ListItemSpacing, Toggleable, + Tooltip, prelude::*, text_for_keystroke, }; use util::ResultExt; use workspace::SplitDirection; use workspace::Workspace; use workspace::item::ItemSettings; -use super::SearchMatch; +use super::{Fold, SearchMatch, Unfold}; use crate::project_search::{ActiveSettings, ProjectSearch}; use crate::{ProjectSearchView, SearchOption, SearchOptions}; @@ -85,6 +85,7 @@ pub struct Delegate { /// column so every row's number right-aligns to the widest one. Recomputed in /// [`Delegate::rebuild_entries`]. pub(crate) max_line_number: u32, + pub(crate) collapsed_paths: HashSet, } pub(crate) enum Entry { @@ -316,6 +317,7 @@ impl Delegate { in_progress_search, unique_files: HashSet::default(), max_line_number: 0, + collapsed_paths: HashSet::default(), }); this @@ -368,7 +370,9 @@ impl Delegate { entries.push(Entry::Header(search_match.path.clone())); last_path = Some(&search_match.path); } - entries.push(Entry::Match(match_index)); + if !self.collapsed_paths.contains(&search_match.path) { + entries.push(Entry::Match(match_index)); + } } self.entries = entries; self.max_line_number = self @@ -394,6 +398,65 @@ impl Delegate { .position(|entry| matches!(entry, Entry::Match(_))) } + pub(crate) fn toggle_group_collapsed(&mut self, path: &ProjectPath) { + if !self.collapsed_paths.remove(path) { + self.collapsed_paths.insert(path.clone()); + } + self.rebuild_entries(); + } + + pub(crate) fn set_selected_group_collapsed( + &mut self, + collapsed: bool, + cx: &mut Context>, + ) { + let path = match self.entries.get(self.selected_index) { + Some(Entry::Match(match_index)) => self + .matches + .get(*match_index) + .map(|search_match| search_match.path.clone()), + Some(Entry::Header(path)) => Some(path.clone()), + Some(Entry::Separator) | None => None, + }; + let Some(path) = path else { + return; + }; + if collapsed == self.collapsed_paths.contains(&path) { + return; + } + + self.toggle_group_collapsed(&path); + + if let Some(index) = self.entries.iter().position(|entry| match entry { + Entry::Header(header_path) => collapsed && *header_path == path, + Entry::Match(match_index) => { + !collapsed + && self + .matches + .get(*match_index) + .is_some_and(|search_match| search_match.path == path) + } + Entry::Separator => false, + }) { + self.selected_index = index; + } + cx.notify(); + } + + pub(crate) fn toggle_all_collapsed(&mut self, cx: &mut Context>) { + if self.collapsed_paths.is_empty() { + self.collapsed_paths = self + .matches + .iter() + .map(|search_match| search_match.path.clone()) + .collect(); + } else { + self.collapsed_paths.clear(); + } + self.rebuild_entries(); + cx.notify(); + } + fn selected_search_match(&self) -> Option<&SearchMatch> { match self.entries.get(self.selected_index)? { Entry::Match(match_index) => self.matches.get(*match_index), @@ -626,7 +689,11 @@ impl PickerDelegate for Delegate { } fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context>) -> bool { - matches!(self.entries.get(ix), Some(Entry::Match(_))) + match self.entries.get(ix) { + Some(Entry::Match(_)) => true, + Some(Entry::Header(path)) => self.collapsed_paths.contains(path), + Some(Entry::Separator) | None => false, + } } fn selected_index(&self) -> usize { @@ -674,6 +741,7 @@ impl PickerDelegate for Delegate { self.matches.clear(); self.entries.clear(); self.unique_files.clear(); + self.collapsed_paths.clear(); self.selected_index = 0; self.active_query = None; cx.notify(); @@ -841,27 +909,83 @@ impl PickerDelegate for Delegate { .color(Color::Muted) .size(IconSize::Small) }); + let is_collapsed = self.collapsed_paths.contains(path); + let toggle_path = path.clone(); + let tooltip_focus_handle = self.focus_handle.clone(); Some( - h_flex() - .w_full() - .min_w_0() - .px(DynamicSpacing::Base06.rems(cx)) - .py_1() - .gap_1p5() - .children(file_icon) + div() + .px_1() .child( h_flex() - .gap_1() - .child(Label::new(file_name).size(LabelSize::Small)) - .when(!directory.is_empty(), |this| { - this.child( - Label::new(directory) - .size(LabelSize::Small) - .color(Color::Muted) - .truncate_start(), - ) - }), + .w_full() + .min_w_0() + .p_1() + .gap_1p5() + .rounded_sm() + .when(selected, |this| { + this.bg(cx.theme().colors().ghost_element_selected) + }) + .child( + h_flex() + .gap_1() + .child( + Disclosure::new( + ("text-finder-fold", ix), + !is_collapsed, + ) + .tooltip(move |_window, cx| { + let (label, action): (_, &dyn gpui::Action) = + if is_collapsed { + ("Unfold", &Unfold) + } else { + ("Fold", &Fold) + }; + Tooltip::with_meta_in( + label, + Some(action), + format!( + "{} to toggle all", + text_for_keystroke( + &Modifiers::alt(), + "click", + cx + ) + ), + &tooltip_focus_handle, + cx, + ) + }) + .on_click( + cx.listener( + move |this, event: &ClickEvent, _window, cx| { + if event.modifiers().alt { + this.delegate.toggle_all_collapsed(cx); + } else { + this.delegate.toggle_group_collapsed( + &toggle_path, + ); + cx.notify(); + } + }, + ), + ), + ) + .children(file_icon), + ) + .child( + h_flex() + .gap_1() + .child(Label::new(file_name).size(LabelSize::Small)) + .when(!directory.is_empty(), |this| { + this.child( + Label::new(directory) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate_start(), + ) + }), + ), ) .into_any_element(), ) @@ -958,6 +1082,7 @@ async fn stream_results_to_picker( delegate.matches.clear(); delegate.entries.clear(); delegate.unique_files.clear(); + delegate.collapsed_paths.clear(); delegate.selected_index = 0; clear_existing = false; } diff --git a/crates/search/src/text_finder/render.rs b/crates/search/src/text_finder/render.rs index d36587ce46d..1f120f0e32d 100644 --- a/crates/search/src/text_finder/render.rs +++ b/crates/search/src/text_finder/render.rs @@ -15,6 +15,9 @@ impl Render for TextFinder { .on_action(cx.listener(Self::split_right)) .on_action(cx.listener(Self::split_up)) .on_action(cx.listener(Self::split_down)) + .on_action(cx.listener(Self::fold)) + .on_action(cx.listener(Self::unfold)) + .on_action(cx.listener(Self::toggle_fold_all)) .child(self.picker.clone()) } } From 4ff55d09e5ccd12fd67c5b5c17b0ffea703a4db0 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 2 Jul 2026 16:32:34 +0200 Subject: [PATCH 030/197] Refactor agent settings UI code (#60274) This PR removes/simplifies some APIs that were leftover after moving the agent settings from the agent panel into the agent settings UI. Behavior/UI should be identical. - Removed `ConfigurationViewTargetAgent` since only a single variant was used - Removed `configuration_view` and `configuration_view_v2` and replaced it with `settings_view` - Removed all the custom configuration views that were replaced by the API key view abstraction - Removed `intitial_title` and `initial_description` and moved it to `ProviderSettingsView` Release Notes: - N/A --- crates/acp_thread/src/connection.rs | 13 +- crates/agent_ui/src/conversation_view.rs | 84 +------ .../src/conversation_view/thread_view.rs | 4 - crates/language_model/src/fake_provider.rs | 23 +- crates/language_model/src/language_model.rs | 110 ++++----- .../language_models/src/provider/anthropic.rs | 190 ++------------- .../src/provider/anthropic_compatible.rs | 46 ++-- .../language_models/src/provider/bedrock.rs | 36 ++- crates/language_models/src/provider/cloud.rs | 75 +++--- .../src/provider/copilot_chat.rs | 92 +++---- .../language_models/src/provider/deepseek.rs | 167 +------------ crates/language_models/src/provider/google.rs | 186 ++------------- .../language_models/src/provider/llama_cpp.rs | 35 +-- .../language_models/src/provider/lmstudio.rs | 34 +-- .../language_models/src/provider/mistral.rs | 182 +------------- crates/language_models/src/provider/ollama.rs | 34 +-- .../language_models/src/provider/open_ai.rs | 224 +----------------- .../src/provider/open_ai_compatible.rs | 45 ++-- .../src/provider/open_router.rs | 176 +------------- .../src/provider/openai_subscribed.rs | 83 +++---- .../language_models/src/provider/opencode.rs | 35 ++- .../src/provider/vercel_ai_gateway.rs | 172 +------------- crates/language_models/src/provider/x_ai.rs | 184 +------------- .../src/pages/llm_providers_page.rs | 96 ++++---- crates/settings_ui/src/settings_ui.rs | 5 +- crates/zed_actions/src/lib.rs | 2 +- 26 files changed, 433 insertions(+), 1900 deletions(-) diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index eaf51978b5d..5f0cf69890d 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -4,7 +4,7 @@ use anyhow::Result; use chrono::{DateTime, Utc}; use collections::{HashMap, HashSet, IndexMap}; use gpui::{Entity, SharedString, Task}; -use language_model::{DisabledReason, LanguageModelProviderId}; +use language_model::DisabledReason; use project::{AgentId, Project}; use serde::{Deserialize, Serialize}; use std::{any::Any, error::Error, fmt, path::PathBuf, rc::Rc}; @@ -421,26 +421,17 @@ impl dyn AgentSessionList { #[derive(Debug)] pub struct AuthRequired { pub description: Option, - pub provider_id: Option, } impl AuthRequired { pub fn new() -> Self { - Self { - description: None, - provider_id: None, - } + Self { description: None } } pub fn with_description(mut self, description: String) -> Self { self.description = Some(description); self } - - pub fn with_language_model_provider(mut self, provider_id: LanguageModelProviderId) -> Self { - self.provider_id = Some(provider_id); - self - } } impl Error for AuthRequired {} diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 350d9c2f912..65632e9406b 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -28,14 +28,14 @@ use file_icons::FileIcons; use fs::Fs; use futures::FutureExt as _; use gpui::{ - Action, Animation, AnimationExt, AnyView, App, ClickEvent, ClipboardItem, CursorStyle, - ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, ListOffset, ListState, - ObjectFit, PlatformDisplay, ScrollHandle, SharedString, StyledText, Subscription, Task, - TextRun, TextStyle, WeakEntity, Window, WindowHandle, div, ease_in_out, img, linear_color_stop, + Action, Animation, AnimationExt, App, ClickEvent, ClipboardItem, CursorStyle, ElementId, Empty, + Entity, EventEmitter, FocusHandle, Focusable, Hsla, ListOffset, ListState, ObjectFit, + PlatformDisplay, ScrollHandle, SharedString, StyledText, Subscription, Task, TextRun, + TextStyle, WeakEntity, Window, WindowHandle, div, ease_in_out, img, linear_color_stop, linear_gradient, list, pulsating_between, }; use language::{Buffer, Language, Rope}; -use language_model::{LanguageModelCompletionError, LanguageModelRegistry}; +use language_model::LanguageModelCompletionError; use markdown::{ CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont, MarkdownStyle, }; @@ -758,9 +758,7 @@ enum AuthState { Ok, Unauthenticated { description: Option>, - configuration_view: Option, pending_auth_method: Option, - _subscription: Option, }, } @@ -1160,14 +1158,7 @@ impl ConversationView { Err(e) => match e.downcast::() { Ok(err) => { cx.update(|window, cx| { - Self::handle_auth_required( - this, - err, - agent.agent_id(), - connection, - window, - cx, - ) + Self::handle_auth_required(this, err, connection, window, cx) }) .log_err(); return; @@ -1449,55 +1440,17 @@ impl ConversationView { fn handle_auth_required( this: WeakEntity, err: AuthRequired, - agent_id: AgentId, connection: Rc, window: &mut Window, cx: &mut App, ) { - let (configuration_view, subscription) = if let Some(provider_id) = &err.provider_id { - let registry = LanguageModelRegistry::global(cx); - - let sub = window.subscribe(®istry, cx, { - let provider_id = provider_id.clone(); - let this = this.clone(); - move |_, ev, window, cx| { - if let language_model::Event::ProviderStateChanged(updated_provider_id) = &ev - && &provider_id == updated_provider_id - && LanguageModelRegistry::global(cx) - .read(cx) - .provider(&provider_id) - .map_or(false, |provider| provider.is_authenticated(cx)) - { - this.update(cx, |this, cx| { - this.reset(window, cx); - }) - .ok(); - } - } - }); - - let view = registry.read(cx).provider(&provider_id).map(|provider| { - provider.configuration_view( - language_model::ConfigurationViewTargetAgent::Other(agent_id.0), - window, - cx, - ) - }); - - (view, Some(sub)) - } else { - (None, None) - }; - this.update(cx, |this, cx| { let description = err .description .map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx))); let auth_state = AuthState::Unauthenticated { pending_auth_method: None, - configuration_view, description, - _subscription: subscription, }; if let Some(connected) = this.as_connected_mut() { connected.auth_state = auth_state; @@ -1963,7 +1916,6 @@ impl ConversationView { let connection = connected.connection.clone(); let AuthState::Unauthenticated { - configuration_view, pending_auth_method, .. } = &mut connected.auth_state @@ -1974,7 +1926,6 @@ impl ConversationView { let agent_telemetry_id = connection.telemetry_id(); if let Some(login_task) = connection.terminal_auth_task(&method, cx) { - configuration_view.take(); pending_auth_method.replace(method.clone()); let project = self.project.clone(); @@ -2043,7 +1994,6 @@ impl ConversationView { return; } - configuration_view.take(); pending_auth_method.replace(method.clone()); let authenticate = connection.authenticate(method, cx); @@ -2292,7 +2242,6 @@ impl ConversationView { &self, connection: &Rc, description: Option<&Entity>, - configuration_view: Option<&AnyView>, pending_auth_method: Option<&acp::AuthMethodId>, window: &mut Window, cx: &Context, @@ -2305,10 +2254,8 @@ impl ConversationView { .agent_display_name(&self.agent.agent_id()) .unwrap_or_else(|| self.agent.agent_id().0); - let show_fallback_description = auth_methods.len() > 1 - && configuration_view.is_none() - && description.is_none() - && pending_auth_method.is_none(); + let show_fallback_description = + auth_methods.len() > 1 && description.is_none() && pending_auth_method.is_none(); let auth_buttons = || { h_flex().justify_end().flex_wrap().gap_1().children( @@ -2383,12 +2330,7 @@ impl ConversationView { .color(Color::Muted), ) } else { - this.children( - configuration_view - .cloned() - .map(|view| div().w_full().child(view)), - ) - .children(description.map(|desc| { + this.children(description.map(|desc| { self.render_markdown( desc.clone(), MarkdownStyle::themed(MarkdownFont::Agent, window, cx), @@ -3245,7 +3187,6 @@ impl ConversationView { } pub(crate) fn reauthenticate(&mut self, window: &mut Window, cx: &mut Context) { - let agent_id = self.agent.agent_id(); self.cancel_request_elicitations(cx); if let Some(active) = self.root_thread_view() { active.update(cx, |active, cx| active.clear_thread_error(cx)); @@ -3256,7 +3197,7 @@ impl ConversationView { return; }; window.defer(cx, |window, cx| { - Self::handle_auth_required(this, AuthRequired::new(), agent_id, connection, window, cx); + Self::handle_auth_required(this, AuthRequired::new(), connection, window, cx); }) } @@ -3288,9 +3229,7 @@ impl ConversationView { if let Some(connected) = this.as_connected_mut() { connected.auth_state = AuthState::Unauthenticated { description: None, - configuration_view: None, pending_auth_method: None, - _subscription: None, }; cx.emit(StateChange); if let Some(view) = connected.active_view() @@ -3431,9 +3370,7 @@ impl Render for ConversationView { auth_state: AuthState::Unauthenticated { description, - configuration_view, pending_auth_method, - _subscription, }, .. }) => v_flex() @@ -3443,7 +3380,6 @@ impl Render for ConversationView { .child(self.render_auth_required_state( connection, description.as_ref(), - configuration_view.as_ref(), pending_auth_method.as_ref(), window, cx, diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 858fc5e840e..0f3c18d2245 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -1490,13 +1490,11 @@ impl ThreadView { let connection = self.thread.read(cx).connection().clone(); window.defer(cx, { - let agent_id = self.agent_id.clone(); let server_view = self.server_view.clone(); move |window, cx| { ConversationView::handle_auth_required( server_view.clone(), AuthRequired::new(), - agent_id, connection, window, cx, @@ -10836,7 +10834,6 @@ impl ThreadView { .on_click(cx.listener({ move |this, _, window, cx| { let server_view = this.server_view.clone(); - let agent_name = this.agent_id.clone(); this.clear_thread_error(cx); if let Some(message) = this.in_flight_prompt.take() { @@ -10849,7 +10846,6 @@ impl ThreadView { ConversationView::handle_auth_required( server_view, AuthRequired::new(), - agent_name, connection, window, cx, diff --git a/crates/language_model/src/fake_provider.rs b/crates/language_model/src/fake_provider.rs index f58130cccf4..a9cb4c5b5fb 100644 --- a/crates/language_model/src/fake_provider.rs +++ b/crates/language_model/src/fake_provider.rs @@ -1,12 +1,12 @@ use crate::{ - AuthenticateError, ConfigurationViewTargetAgent, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, - LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelToolChoice, + AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, + LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, + LanguageModelToolChoice, }; use anyhow::anyhow; use futures::{FutureExt, channel::mpsc, future::BoxFuture, stream::BoxStream, stream::StreamExt}; -use gpui::{AnyView, App, AsyncApp, Entity, Task, Window}; +use gpui::{App, AsyncApp, Entity, Task}; use http_client::Result; use parking_lot::Mutex; use std::sync::{ @@ -68,17 +68,8 @@ impl LanguageModelProvider for FakeLanguageModelProvider { Task::ready(Ok(())) } - fn configuration_view( - &self, - _target_agent: ConfigurationViewTargetAgent, - _window: &mut Window, - _: &mut App, - ) -> AnyView { - unimplemented!() - } - - fn reset_credentials(&self, _: &mut App) -> Task> { - Task::ready(Ok(())) + fn settings_view(&self, _: &mut App) -> Option { + None } } diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 7a8aa00f92d..2e060049d0b 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -15,6 +15,8 @@ use icons::IconName; use parking_lot::Mutex; use std::sync::Arc; +pub type CreateProviderSettingsView = Arc AnyView + 'static>; + pub use crate::api_key::{ApiKey, ApiKeyState}; pub use crate::registry::*; pub use crate::request::{LanguageModelImageExt, gpui_size_to_image_size, image_size_to_gpui}; @@ -320,13 +322,11 @@ pub trait LanguageModelProvider: 'static { } fn is_authenticated(&self, cx: &App) -> bool; fn authenticate(&self, cx: &mut App) -> Task>; - fn configuration_view( - &self, - target_agent: ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView; - fn reset_credentials(&self, cx: &mut App) -> Task>; + fn settings_view(&self, cx: &mut App) -> Option; + + fn set_api_key(&self, _key: Option, _cx: &mut App) -> Task> { + Task::ready(Ok(())) + } /// Copy shown when this provider rejects a request as unauthenticated /// (HTTP 401). The default assumes API-key authentication; providers using @@ -335,7 +335,7 @@ pub trait LanguageModelProvider: 'static { fn authentication_error_message(&self) -> SharedString { format!( "The API key for {} is invalid or has expired. \ - Update your key via the Agent Panel settings to continue.", + Update your key in Settings > AI > LLM Providers to continue.", self.name().0 ) .into() @@ -348,45 +348,12 @@ pub trait LanguageModelProvider: 'static { fn missing_credentials_error_message(&self) -> SharedString { format!( "No API key is configured for {}. \ - Add your key via the Agent Panel settings to continue.", + Add your key in Settings > AI > LLM Providers to continue.", self.name().0 ) .into() } - /// Returns the provider's configuration UI together with how it prefers to - /// be presented: [`ProviderConfigurationView::Inline`] for a compact control - /// that can sit in a list row (e.g. a single API-key field), or - /// [`ProviderConfigurationView::SubPage`] for a richer view that needs its - /// own surface. - /// - /// The default reuses [`Self::configuration_view`] as a sub-page, so - /// providers only override this when they have a compact inline form. - fn configuration_view_v2( - &self, - target_agent: ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> ProviderConfigurationView { - ProviderConfigurationView::SubPage(self.configuration_view(target_agent, window, cx)) - } - - fn inline_title(&self, _cx: &App) -> Option { - None - } - - fn inline_description(&self, _cx: &App) -> Option { - None - } - - fn api_key_configuration(&self, _cx: &App) -> Option { - None - } - - fn set_api_key(&self, _key: String, _cx: &mut App) -> Task> { - Task::ready(Ok(())) - } - /// Copy shown the first time a user enables fast mode for a model from /// this provider. Returning `None` skips the confirmation prompt and lets /// the toggle apply silently. @@ -395,11 +362,55 @@ pub trait LanguageModelProvider: 'static { } } -/// How a provider's configuration UI prefers to be presented by the settings UI. +/// A provider's settings UI, modeled as mutually exclusive presentation modes. #[derive(Clone)] -pub enum ProviderConfigurationView { - Inline { view: AnyView }, - SubPage(AnyView), +pub enum ProviderSettingsView { + ApiKey(ApiKeyConfiguration), + Inline(InlineProviderSettings), + SubPage(SubPageProviderSettings), +} + +#[derive(Clone)] +pub struct InlineProviderSettings { + pub title: Option, + pub description: Option, + pub create_view: CreateProviderSettingsView, +} + +#[derive(Clone)] +pub struct SubPageProviderSettings { + pub description: Option, + pub create_view: CreateProviderSettingsView, +} + +impl SubPageProviderSettings { + pub fn new(create_view: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self { + Self { + description: None, + create_view: Arc::new(create_view), + } + } + + pub fn description(mut self, description: InlineDescription) -> Self { + self.description = Some(description); + self + } +} + +impl ApiKeyConfiguration { + pub fn new( + has_key: bool, + is_from_env_var: bool, + env_var_name: SharedString, + api_key_url: SharedString, + ) -> Self { + Self { + has_key, + is_from_env_var, + env_var_name, + api_key_url, + } + } } /// A live snapshot of a single-API-key provider's credential state, used by the @@ -430,13 +441,6 @@ pub struct FastModeConfirmation { pub message: SharedString, } -#[derive(Default, Clone, PartialEq, Eq)] -pub enum ConfigurationViewTargetAgent { - #[default] - ZedAgent, - Other(SharedString), -} - pub trait LanguageModelProviderState: 'static { type ObservableEntity; diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 1841a9619d2..19ba2b45678 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -5,21 +5,19 @@ use anyhow::Result; use collections::BTreeMap; use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ ANTHROPIC_PROVIDER_ID, ANTHROPIC_PROVIDER_NAME, ApiKeyConfiguration, ApiKeyState, - AuthenticateError, ConfigurationViewTargetAgent, EnvVar, FastModeConfirmation, IconOrSvg, - LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, - LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, - LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, RateLimiter, - env_var, + AuthenticateError, EnvVar, FastModeConfirmation, IconOrSvg, LanguageModel, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, + LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, + ProviderSettingsView, RateLimiter, env_var, }; use settings::{Settings, SettingsStore}; use std::sync::{Arc, LazyLock}; -use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; -use ui_input::InputField; -use util::ResultExt; +use ui::IconName; pub use anthropic::completion::{AnthropicEventMapper, AnthropicPromptCacheMode, into_anthropic}; pub use settings::AnthropicAvailableModel as AvailableModel; @@ -275,34 +273,19 @@ impl LanguageModelProvider for AnthropicLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - target_agent: ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), target_agent, window, cx)) - .into() - } - - fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) - } - - fn api_key_configuration(&self, cx: &App) -> Option { + fn settings_view(&self, cx: &mut App) -> Option { let state = self.state.read(cx); - Some(ApiKeyConfiguration { - has_key: state.api_key_state.has_key(), - is_from_env_var: state.api_key_state.is_from_env_var(), - env_var_name: state.api_key_state.env_var_name().clone(), - api_key_url: "https://console.anthropic.com/settings/keys".into(), - }) + Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new( + state.api_key_state.has_key(), + state.api_key_state.is_from_env_var(), + state.api_key_state.env_var_name().clone(), + "https://console.anthropic.com/settings/keys".into(), + ))) } - fn set_api_key(&self, key: String, cx: &mut App) -> Task> { + fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> { self.state - .update(cx, |state, cx| state.set_api_key(Some(key), cx)) + .update(cx, |state, cx| state.set_api_key(api_key, cx)) } fn fast_mode_confirmation(&self, _cx: &App) -> Option { @@ -564,144 +547,3 @@ impl LanguageModel for AnthropicModel { async move { Ok(future.await?.boxed()) }.boxed() } } - -struct ConfigurationView { - api_key_editor: Entity, - state: Entity, - load_credentials_task: Option>, - target_agent: ConfigurationViewTargetAgent, -} - -impl ConfigurationView { - const PLACEHOLDER_TEXT: &'static str = "sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; - - fn new( - state: Entity, - target_agent: ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut Context, - ) -> Self { - cx.observe(&state, |_, _, cx| { - cx.notify(); - }) - .detach(); - - let load_credentials_task = Some(cx.spawn({ - let state = state.clone(); - async move |this, cx| { - let task = state.update(cx, |state, cx| state.authenticate(cx)); - // We don't log an error, because "not signed in" is also an error. - let _ = task.await; - this.update(cx, |this, cx| { - this.load_credentials_task = None; - cx.notify(); - }) - .log_err(); - } - })); - - Self { - api_key_editor: cx.new(|cx| InputField::new(window, cx, Self::PLACEHOLDER_TEXT)), - state, - load_credentials_task, - target_agent, - } - } - - fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_editor.read(cx).text(cx); - if api_key.is_empty() { - return; - } - - // url changes can cause the editor to be displayed again - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(Some(api_key), cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(None, cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn should_render_editor(&self, cx: &mut Context) -> bool { - !self.state.read(cx).is_authenticated() - } -} - -impl Render for ConfigurationView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); - let configured_card_label = if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = AnthropicLanguageModelProvider::api_url(cx); - if api_url == ANTHROPIC_API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", api_url) - } - }; - - if self.load_credentials_task.is_some() { - div() - .child(Label::new("Loading credentials...")) - .into_any_element() - } else if self.should_render_editor(cx) { - v_flex() - .size_full() - .on_action(cx.listener(Self::save_api_key)) - .child(Label::new(format!("To use {}, you need to add an API key. Follow these steps:", match &self.target_agent { - ConfigurationViewTargetAgent::ZedAgent => "Zed's agent with Anthropic".into(), - ConfigurationViewTargetAgent::Other(agent) => agent.clone(), - }))) - .child( - List::new() - .child( - ListBulletItem::new("") - .child(Label::new("Create one by visiting")) - .child(ButtonLink::new("Anthropic's settings", "https://console.anthropic.com/settings/keys")) - ) - .child( - ListBulletItem::new("Paste your API key below and hit enter to start using the agent") - ) - ) - .child(self.api_key_editor.clone()) - .child( - Label::new( - format!("You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."), - ) - .size(LabelSize::Small) - .color(Color::Muted) - .mt_0p5(), - ) - .into_any_element() - } else { - ConfiguredApiCard::new("anthropic-reset-key", configured_card_label) - .disabled(env_var_set) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) - .when(env_var_set, |this| { - this.tooltip_label(format!( - "To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable." - )) - }) - .into_any_element() - } - } -} diff --git a/crates/language_models/src/provider/anthropic_compatible.rs b/crates/language_models/src/provider/anthropic_compatible.rs index b0790045324..8a3ddd85da4 100644 --- a/crates/language_models/src/provider/anthropic_compatible.rs +++ b/crates/language_models/src/provider/anthropic_compatible.rs @@ -3,13 +3,14 @@ use anthropic::{AnthropicError, AnthropicModelMode}; use anyhow::Result; use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, App, AppContext, AsyncApp, Entity, Task, Window}; +use gpui::{App, AppContext, AsyncApp, Entity, Task}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelToolChoice, RateLimiter, + LanguageModelRequest, LanguageModelToolChoice, ProviderSettingsView, RateLimiter, + SubPageProviderSettings, }; use settings::Settings; use std::sync::Arc; @@ -181,32 +182,27 @@ impl LanguageModelProvider for AnthropicCompatibleLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| { - ApiCompatibleProviderConfigurationView::new( - self.state.clone(), - "Anthropic", - API_KEY_PLACEHOLDER, - window, - cx, - ) - }) - .into() + fn settings_view(&self, _cx: &mut App) -> Option { + let state = self.state.clone(); + Some(ProviderSettingsView::SubPage(SubPageProviderSettings::new( + move |window, cx| { + cx.new(|cx| { + ApiCompatibleProviderConfigurationView::new( + state.clone(), + "Anthropic", + API_KEY_PLACEHOLDER, + window, + cx, + ) + }) + .into() + }, + ))) } - fn set_api_key(&self, key: String, cx: &mut App) -> Task> { + fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> { self.state - .update(cx, |state, cx| state.set_api_key(Some(key), cx)) - } - - fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) + .update(cx, |state, cx| state.set_api_key(api_key, cx)) } } diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index 4d843009f76..ee673577c66 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -26,8 +26,7 @@ use collections::{BTreeMap, HashMap}; use credentials_provider::CredentialsProvider; use futures::{FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream}; use gpui::{ - AnyView, App, AsyncApp, Context, Entity, FocusHandle, Subscription, Task, TaskExt, Window, - actions, + App, AsyncApp, Context, Entity, FocusHandle, Subscription, Task, TaskExt, Window, actions, }; use gpui_tokio::Tokio; use http_client::HttpClient; @@ -36,8 +35,8 @@ use language_model::{ LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, - LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, RateLimiter, Role, - TokenUsage, env_var, + LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, ProviderSettingsView, + RateLimiter, Role, SubPageProviderSettings, TokenUsage, env_var, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -461,12 +460,6 @@ impl LanguageModelProvider for BedrockLanguageModelProvider { IconOrSvg::Icon(IconName::AiBedrock) } - fn inline_description(&self, _cx: &App) -> Option { - Some(InlineDescription::Text( - "To use Zed's agent with Bedrock, set a custom authentication strategy in your settings or use static credentials.".into(), - )) - } - fn default_model(&self, _cx: &App) -> Option> { Some(self.create_language_model(bedrock::Model::default())) } @@ -523,18 +516,17 @@ impl LanguageModelProvider for BedrockLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) - .into() - } - - fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state.update(cx, |state, cx| state.reset_auth(cx)) + fn settings_view(&self, _cx: &mut App) -> Option { + let state = self.state.clone(); + Some(ProviderSettingsView::SubPage( + SubPageProviderSettings::new(move |window, cx| { + cx.new(|cx| ConfigurationView::new(state.clone(), window, cx)) + .into() + }) + .description(InlineDescription::Text( + "To use Zed's agent with Bedrock, set a custom authentication strategy in your settings or use static credentials.".into(), + )), + )) } } diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 926f99eae1b..06131314eef 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -9,11 +9,11 @@ use cloud_api_types::Plan; use futures::FutureExt; use futures::StreamExt; use futures::future::BoxFuture; -use gpui::{AnyElement, AnyView, App, AppContext, Context, Entity, Subscription, Task, TaskExt}; +use gpui::{AnyElement, App, AppContext, Context, Entity, Subscription, Task, TaskExt}; use language_model::{ AuthenticateError, FastModeConfirmation, IconOrSvg, InlineDescription, LanguageModel, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, - LanguageModelProviderState, ProviderConfigurationView, ZED_CLOUD_PROVIDER_ID, + LanguageModelProviderState, ProviderSettingsView, ZED_CLOUD_PROVIDER_ID, ZED_CLOUD_PROVIDER_NAME, }; use language_models_cloud::{CloudLlmTokenProvider, CloudModelProvider}; @@ -360,37 +360,13 @@ impl LanguageModelProvider for CloudLanguageModelProvider { }) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - _: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|_| ConfigurationView::new(self.state.clone(), false)) - .into() - } - - fn configuration_view_v2( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - _window: &mut Window, - cx: &mut App, - ) -> ProviderConfigurationView { - ProviderConfigurationView::Inline { - view: cx - .new(|_| ConfigurationView::new(self.state.clone(), true)) - .into(), - } - } - - fn inline_description(&self, cx: &App) -> Option { + fn settings_view(&self, cx: &mut App) -> Option { let state = self.state.read(cx); let user_store = state.user_store.read(cx); let is_zed_model_provider_enabled = user_store .current_organization_configuration() .map_or(true, |config| config.is_zed_model_provider_enabled); - - Some(InlineDescription::Text( + let description = InlineDescription::Text( zed_ai_description( !state.is_signed_out(cx), user_store.plan(), @@ -398,27 +374,34 @@ impl LanguageModelProvider for CloudLanguageModelProvider { user_store.trial_started_at().is_none(), ) .into(), - )) - } + ); - fn inline_title(&self, cx: &App) -> Option { - let state = self.state.read(cx); - if state.is_signed_out(cx) { - return None; - } - let plan_name = match state.user_store.read(cx).plan()? { - Plan::ZedPro => "Pro", - Plan::ZedProTrial => "Pro Trial", - Plan::ZedStudent => "Student", - Plan::ZedBusiness => "Business", - Plan::ZedVip => "VIP", - Plan::ZedFree => return None, + let title = if state.is_signed_out(cx) { + None + } else { + match state.user_store.read(cx).plan() { + Some(Plan::ZedPro) => Some("Subscribed to Pro".into()), + Some(Plan::ZedProTrial) => Some("Subscribed to Pro Trial".into()), + Some(Plan::ZedStudent) => Some("Subscribed to Student".into()), + Some(Plan::ZedBusiness) => Some("Subscribed to Business".into()), + Some(Plan::ZedVip) => Some("Subscribed to VIP".into()), + Some(Plan::ZedFree) | None => None, + } }; - Some(format!("Subscribed to {plan_name}").into()) - } - fn reset_credentials(&self, _cx: &mut App) -> Task> { - Task::ready(Ok(())) + Some(ProviderSettingsView::Inline( + language_model::InlineProviderSettings { + title, + description: Some(description), + create_view: Arc::new({ + let state = self.state.clone(); + move |_window, cx| { + cx.new(|_| ConfigurationView::new(state.clone(), true)) + .into() + } + }), + }, + )) } fn authentication_error_message(&self) -> SharedString { diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index 94bc00e72d5..db845e85796 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -16,7 +16,7 @@ use copilot_chat::{ use futures::future::BoxFuture; use futures::stream::BoxStream; use futures::{FutureExt, Stream, StreamExt}; -use gpui::{AnyView, App, AsyncApp, Entity, Subscription, Task}; +use gpui::{App, AsyncApp, Entity, Subscription, Task}; use http_client::StatusCode; use language::language_settings::all_language_settings; use language_model::{ @@ -25,7 +25,7 @@ use language_model::{ LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolSchemaFormat, - LanguageModelToolUse, MessageContent, ProviderConfigurationView, RateLimiter, Role, StopReason, + LanguageModelToolUse, MessageContent, ProviderSettingsView, RateLimiter, Role, StopReason, TokenUsage, }; use settings::SettingsStore; @@ -177,76 +177,42 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider { Task::ready(Err(err.into())) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - _: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| { - copilot_ui::ConfigurationView::new( - |cx| { - CopilotChat::global(cx) - .map(|m| m.read(cx).is_authenticated()) - .unwrap_or(false) - }, - copilot_ui::ConfigurationMode::Chat, - cx, - ) - }) - .into() - } - - fn configuration_view_v2( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - _window: &mut Window, - cx: &mut App, - ) -> ProviderConfigurationView { - // GitHub Copilot's control is just a sign-in/out button, so render it - // inline rather than behind a sub-page. The explanatory copy is surfaced - // via `inline_description` (the row's left column), so the view itself - // renders compactly. - ProviderConfigurationView::Inline { - view: cx - .new(|cx| { - copilot_ui::ConfigurationView::new( - |cx| { - CopilotChat::global(cx) - .map(|m| m.read(cx).is_authenticated()) - .unwrap_or(false) - }, - copilot_ui::ConfigurationMode::Chat, - cx, - ) - .compact() - }) - .into(), - } - } - - fn inline_title(&self, cx: &App) -> Option { - if self.state.read(cx).is_authenticated(cx) { + fn settings_view(&self, cx: &mut App) -> Option { + let is_authenticated = self.state.read(cx).is_authenticated(cx); + let title = if is_authenticated { None } else { Some("Configure Copilot".into()) - } - } - - fn inline_description(&self, cx: &App) -> Option { - if self.state.read(cx).is_authenticated(cx) { + }; + let description = if is_authenticated { None } else { Some(language_model::InlineDescription::Text( "Requires an active GitHub Copilot subscription.".into(), )) - } - } + }; - fn reset_credentials(&self, _cx: &mut App) -> Task> { - Task::ready(Err(anyhow!( - "Signing out of GitHub Copilot Chat is currently not supported." - ))) + Some(ProviderSettingsView::Inline( + language_model::InlineProviderSettings { + title, + description, + create_view: Arc::new(|_window, cx| { + cx.new(|cx| { + copilot_ui::ConfigurationView::new( + |cx| { + CopilotChat::global(cx) + .map(|m| m.read(cx).is_authenticated()) + .unwrap_or(false) + }, + copilot_ui::ConfigurationMode::Chat, + cx, + ) + .compact() + }) + .into() + }), + }, + )) } } diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs index 943b3861e3c..f448ae2be68 100644 --- a/crates/language_models/src/provider/deepseek.rs +++ b/crates/language_models/src/provider/deepseek.rs @@ -5,7 +5,7 @@ use deepseek::DEEPSEEK_API_URL; use futures::Stream; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, @@ -13,16 +13,14 @@ use language_model::{ LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, - RateLimiter, Role, StopReason, TokenUsage, env_var, + ProviderSettingsView, RateLimiter, Role, StopReason, TokenUsage, env_var, }; pub use settings::DeepseekAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::pin::Pin; use std::sync::{Arc, LazyLock}; -use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; -use ui_input::InputField; -use util::ResultExt; +use ui::IconName; use language_model::util::{fix_streamed_json, parse_tool_arguments}; @@ -197,34 +195,19 @@ impl LanguageModelProvider for DeepSeekLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) - .into() - } - - fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) - } - - fn api_key_configuration(&self, cx: &App) -> Option { + fn settings_view(&self, cx: &mut App) -> Option { let state = self.state.read(cx); - Some(ApiKeyConfiguration { - has_key: state.api_key_state.has_key(), - is_from_env_var: state.api_key_state.is_from_env_var(), - env_var_name: state.api_key_state.env_var_name().clone(), - api_key_url: "https://platform.deepseek.com/api_keys".into(), - }) + Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new( + state.api_key_state.has_key(), + state.api_key_state.is_from_env_var(), + state.api_key_state.env_var_name().clone(), + "https://platform.deepseek.com/api_keys".into(), + ))) } - fn set_api_key(&self, key: String, cx: &mut App) -> Task> { + fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> { self.state - .update(cx, |state, cx| state.set_api_key(Some(key), cx)) + .update(cx, |state, cx| state.set_api_key(api_key, cx)) } } @@ -652,129 +635,3 @@ impl DeepSeekEventMapper { events } } - -struct ConfigurationView { - api_key_editor: Entity, - state: Entity, - load_credentials_task: Option>, -} - -impl ConfigurationView { - fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { - let api_key_editor = - cx.new(|cx| InputField::new(window, cx, "sk-00000000000000000000000000000000")); - - cx.observe(&state, |_, _, cx| { - cx.notify(); - }) - .detach(); - - let load_credentials_task = Some(cx.spawn({ - let state = state.clone(); - async move |this, cx| { - if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) { - let _ = task.await; - } - - this.update(cx, |this, cx| { - this.load_credentials_task = None; - cx.notify(); - }) - .log_err(); - } - })); - - Self { - api_key_editor, - state, - load_credentials_task, - } - } - - fn save_api_key(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); - if api_key.is_empty() { - return; - } - - let state = self.state.clone(); - cx.spawn(async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(Some(api_key), cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn(async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(None, cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn should_render_editor(&self, cx: &mut Context) -> bool { - !self.state.read(cx).is_authenticated() - } -} - -impl Render for ConfigurationView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); - let configured_card_label = if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = DeepSeekLanguageModelProvider::api_url(cx); - if api_url == DEEPSEEK_API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", api_url) - } - }; - - if self.load_credentials_task.is_some() { - div() - .child(Label::new("Loading credentials...")) - .into_any_element() - } else if self.should_render_editor(cx) { - v_flex() - .size_full() - .on_action(cx.listener(Self::save_api_key)) - .child(Label::new("To use DeepSeek in Zed, you need an API key:")) - .child( - List::new() - .child( - ListBulletItem::new("") - .child(Label::new("Get your API key from the")) - .child(ButtonLink::new( - "DeepSeek console", - "https://platform.deepseek.com/api_keys", - )), - ) - .child(ListBulletItem::new( - "Paste your API key below and hit enter to start using the assistant", - )), - ) - .child(self.api_key_editor.clone()) - .child( - Label::new(format!( - "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed." - )) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .into_any_element() - } else { - ConfiguredApiCard::new("deepseek-reset-key", configured_card_label) - .disabled(env_var_set) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) - .into_any_element() - } - } -} diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs index 8a289fd2e05..7906e8f301e 100644 --- a/crates/language_models/src/provider/google.rs +++ b/crates/language_models/src/provider/google.rs @@ -4,17 +4,17 @@ use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; use google_ai::GenerateContentResponse; pub use google_ai::completion::{GoogleEventMapper, into_google}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ - ApiKeyConfiguration, AuthenticateError, ConfigurationViewTargetAgent, EnvVar, - LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelToolChoice, - LanguageModelToolSchemaFormat, + ApiKeyConfiguration, AuthenticateError, EnvVar, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelToolChoice, LanguageModelToolSchemaFormat, }; use language_model::{ GOOGLE_PROVIDER_ID, GOOGLE_PROVIDER_NAME, IconOrSvg, LanguageModel, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, RateLimiter, + LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, + ProviderSettingsView, RateLimiter, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -22,9 +22,7 @@ pub use settings::GoogleAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; -use ui_input::InputField; -use util::ResultExt; +use ui::IconName; use language_model::ApiKeyState; @@ -222,34 +220,19 @@ impl LanguageModelProvider for GoogleLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), target_agent, window, cx)) - .into() - } - - fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) - } - - fn api_key_configuration(&self, cx: &App) -> Option { + fn settings_view(&self, cx: &mut App) -> Option { let state = self.state.read(cx); - Some(ApiKeyConfiguration { - has_key: state.api_key_state.has_key(), - is_from_env_var: state.api_key_state.is_from_env_var(), - env_var_name: state.api_key_state.env_var_name().clone(), - api_key_url: "https://aistudio.google.com/app/apikey".into(), - }) + Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new( + state.api_key_state.has_key(), + state.api_key_state.is_from_env_var(), + state.api_key_state.env_var_name().clone(), + "https://aistudio.google.com/app/apikey".into(), + ))) } - fn set_api_key(&self, key: String, cx: &mut App) -> Task> { + fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> { self.state - .update(cx, |state, cx| state.set_api_key(Some(key), cx)) + .update(cx, |state, cx| state.set_api_key(api_key, cx)) } } @@ -388,142 +371,3 @@ impl LanguageModel for GoogleLanguageModel { async move { Ok(future.await?.boxed()) }.boxed() } } - -struct ConfigurationView { - api_key_editor: Entity, - state: Entity, - target_agent: language_model::ConfigurationViewTargetAgent, - load_credentials_task: Option>, -} - -impl ConfigurationView { - fn new( - state: Entity, - target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut Context, - ) -> Self { - cx.observe(&state, |_, _, cx| { - cx.notify(); - }) - .detach(); - - let load_credentials_task = Some(cx.spawn_in(window, { - let state = state.clone(); - async move |this, cx| { - if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) { - // We don't log an error, because "not signed in" is also an error. - let _ = task.await; - } - this.update(cx, |this, cx| { - this.load_credentials_task = None; - cx.notify(); - }) - .log_err(); - } - })); - - Self { - api_key_editor: cx.new(|cx| InputField::new(window, cx, "AIzaSy...")), - target_agent, - state, - load_credentials_task, - } - } - - fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); - if api_key.is_empty() { - return; - } - - // url changes can cause the editor to be displayed again - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(Some(api_key), cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(None, cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn should_render_editor(&self, cx: &mut Context) -> bool { - !self.state.read(cx).is_authenticated() - } -} - -impl Render for ConfigurationView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); - let configured_card_label = if env_var_set { - format!( - "API key set in {} environment variable", - API_KEY_ENV_VAR.name - ) - } else { - let api_url = GoogleLanguageModelProvider::api_url(cx); - if api_url == google_ai::API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", api_url) - } - }; - - if self.load_credentials_task.is_some() { - div() - .child(Label::new("Loading credentials...")) - .into_any_element() - } else if self.should_render_editor(cx) { - v_flex() - .size_full() - .on_action(cx.listener(Self::save_api_key)) - .child(Label::new(format!("To use {}, you need to add an API key. Follow these steps:", match &self.target_agent { - ConfigurationViewTargetAgent::ZedAgent => "Zed's agent with Google AI".into(), - ConfigurationViewTargetAgent::Other(agent) => agent.clone(), - }))) - .child( - List::new() - .child( - ListBulletItem::new("") - .child(Label::new("Create one by visiting")) - .child(ButtonLink::new("Google AI's console", "https://aistudio.google.com/app/apikey")) - ) - .child( - ListBulletItem::new("Paste your API key below and hit enter to start using the agent") - ) - ) - .child(self.api_key_editor.clone()) - .child( - Label::new( - format!("You can also set the {GEMINI_API_KEY_VAR_NAME} environment variable and restart Zed."), - ) - .size(LabelSize::Small).color(Color::Muted), - ) - .into_any_element() - } else { - ConfiguredApiCard::new("google-reset-key", configured_card_label) - .disabled(env_var_set) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) - .when(env_var_set, |this| { - this.tooltip_label(format!("To reset your API key, make sure {GEMINI_API_KEY_VAR_NAME} and {GOOGLE_AI_API_KEY_VAR_NAME} environment variables are unset.")) - }) - .into_any_element() - } - } -} diff --git a/crates/language_models/src/provider/llama_cpp.rs b/crates/language_models/src/provider/llama_cpp.rs index ed207d4b47b..19f0e9f2962 100644 --- a/crates/language_models/src/provider/llama_cpp.rs +++ b/crates/language_models/src/provider/llama_cpp.rs @@ -4,7 +4,7 @@ use credentials_provider::CredentialsProvider; use fs::Fs; use futures::Stream; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt}; +use gpui::{App, AsyncApp, Context, Entity, Task, TaskExt}; use http_client::{CustomHeaders, HttpClient}; use language_model::util::parse_tool_arguments; use language_model::{ @@ -12,8 +12,8 @@ use language_model::{ LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, - LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, RateLimiter, Role, - StopReason, TokenUsage, env_var, + LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, ProviderSettingsView, + RateLimiter, Role, StopReason, SubPageProviderSettings, TokenUsage, env_var, }; use llama_cpp::{ LLAMA_CPP_API_URL, ModelEntry, Props, get_models, get_props, stream_chat_completion, @@ -562,12 +562,6 @@ impl LanguageModelProvider for LlamaCppLanguageModelProvider { None } - fn inline_description(&self, _cx: &App) -> Option { - Some(InlineDescription::Text( - "Run local models on your machine with LlamaCpp.".into(), - )) - } - fn provided_models(&self, cx: &App) -> Vec> { let settings = LlamaCppLanguageModelProvider::settings(cx); let effective = compute_effective_models(&self.state.read(cx).fetched_models, settings); @@ -603,20 +597,17 @@ impl LanguageModelProvider for LlamaCppLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { + fn settings_view(&self, _cx: &mut App) -> Option { let state = self.state.clone(); - cx.new(|cx| ConfigurationView::new(state, window, cx)) - .into() - } - - fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) + Some(ProviderSettingsView::SubPage( + SubPageProviderSettings::new(move |window, cx| { + cx.new(|cx| ConfigurationView::new(state.clone(), window, cx)) + .into() + }) + .description(InlineDescription::Text( + "Run local models on your machine with LlamaCpp.".into(), + )), + )) } } diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs index 362db21070e..3f6e9fb46e2 100644 --- a/crates/language_models/src/provider/lmstudio.rs +++ b/crates/language_models/src/provider/lmstudio.rs @@ -3,7 +3,7 @@ use credentials_provider::CredentialsProvider; use fs::Fs; use futures::Stream; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, Subscription, Task, TaskExt}; +use gpui::{App, AsyncApp, Context, Entity, Subscription, Task, TaskExt}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, @@ -13,7 +13,7 @@ use language_model::{ use language_model::{ InlineDescription, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, RateLimiter, Role, + LanguageModelRequest, ProviderSettingsView, RateLimiter, Role, SubPageProviderSettings, }; use lmstudio::{LMSTUDIO_API_URL, ModelType, get_models}; @@ -252,12 +252,6 @@ impl LanguageModelProvider for LmStudioLanguageModelProvider { IconOrSvg::Icon(IconName::AiLmStudio) } - fn inline_description(&self, _cx: &App) -> Option { - Some(InlineDescription::Text( - "Run local LLMs like Llama, Phi, and Qwen with LM Studio.".into(), - )) - } - fn default_model(&self, _: &App) -> Option> { // We shouldn't try to select default model, because it might lead to a load call for an unloaded model. // In a constrained environment where user might not have enough resources it'll be a bad UX to select something @@ -318,19 +312,17 @@ impl LanguageModelProvider for LmStudioLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - _window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), _window, cx)) - .into() - } - - fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) + fn settings_view(&self, _cx: &mut App) -> Option { + let state = self.state.clone(); + Some(ProviderSettingsView::SubPage( + SubPageProviderSettings::new(move |window, cx| { + cx.new(|cx| ConfigurationView::new(state.clone(), window, cx)) + .into() + }) + .description(InlineDescription::Text( + "Run local LLMs like Llama, Phi, and Qwen with LM Studio.".into(), + )), + )) } } diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index de9f385c8e5..f12f33a6f35 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -3,15 +3,15 @@ use collections::{BTreeMap, HashMap}; use credentials_provider::CredentialsProvider; use futures::{FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, Global, SharedString, Task, TaskExt, Window}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Task}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, - LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, RateLimiter, Role, - StopReason, TokenUsage, env_var, + LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, ProviderSettingsView, + RateLimiter, Role, StopReason, TokenUsage, env_var, }; pub use mistral::{MISTRAL_API_URL, StreamResponse}; pub use settings::MistralAvailableModel as AvailableModel; @@ -19,9 +19,7 @@ use settings::{Settings, SettingsStore}; use std::pin::Pin; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; -use ui_input::InputField; -use util::ResultExt; +use ui::IconName; use language_model::util::{fix_streamed_json, parse_tool_arguments}; @@ -222,34 +220,19 @@ impl LanguageModelProvider for MistralLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) - .into() - } - - fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) - } - - fn api_key_configuration(&self, cx: &App) -> Option { + fn settings_view(&self, cx: &mut App) -> Option { let state = self.state.read(cx); - Some(ApiKeyConfiguration { - has_key: state.api_key_state.has_key(), - is_from_env_var: state.api_key_state.is_from_env_var(), - env_var_name: state.api_key_state.env_var_name().clone(), - api_key_url: "https://console.mistral.ai/api-keys".into(), - }) + Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new( + state.api_key_state.has_key(), + state.api_key_state.is_from_env_var(), + state.api_key_state.env_var_name().clone(), + "https://console.mistral.ai/api-keys".into(), + ))) } - fn set_api_key(&self, key: String, cx: &mut App) -> Task> { + fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> { self.state - .update(cx, |state, cx| state.set_api_key(Some(key), cx)) + .update(cx, |state, cx| state.set_api_key(api_key, cx)) } } @@ -765,145 +748,6 @@ struct RawToolCall { arguments: String, } -struct ConfigurationView { - api_key_editor: Entity, - state: Entity, - load_credentials_task: Option>, -} - -impl ConfigurationView { - fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { - let api_key_editor = - cx.new(|cx| InputField::new(window, cx, "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")); - - cx.observe(&state, |_, _, cx| { - cx.notify(); - }) - .detach(); - - let load_credentials_task = Some(cx.spawn_in(window, { - let state = state.clone(); - async move |this, cx| { - if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) { - // We don't log an error, because "not signed in" is also an error. - let _ = task.await; - } - - this.update(cx, |this, cx| { - this.load_credentials_task = None; - cx.notify(); - }) - .log_err(); - } - })); - - Self { - api_key_editor, - state, - load_credentials_task, - } - } - - fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); - if api_key.is_empty() { - return; - } - - // url changes can cause the editor to be displayed again - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(Some(api_key), cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(None, cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn should_render_api_key_editor(&self, cx: &mut Context) -> bool { - !self.state.read(cx).is_authenticated() - } -} - -impl Render for ConfigurationView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); - let configured_card_label = if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = MistralLanguageModelProvider::api_url(cx); - if api_url == MISTRAL_API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", api_url) - } - }; - - if self.load_credentials_task.is_some() { - div().child(Label::new("Loading credentials...")).into_any() - } else if self.should_render_api_key_editor(cx) { - v_flex() - .size_full() - .on_action(cx.listener(Self::save_api_key)) - .child(Label::new("To use Zed's agent with Mistral, you need to add an API key. Follow these steps:")) - .child( - List::new() - .child( - ListBulletItem::new("") - .child(Label::new("Create one by visiting")) - .child(ButtonLink::new("Mistral's console", "https://console.mistral.ai/api-keys")) - ) - .child( - ListBulletItem::new("Ensure your Mistral account has credits") - ) - .child( - ListBulletItem::new("Paste your API key below and hit enter to start using the assistant") - ), - ) - .child(self.api_key_editor.clone()) - .child( - Label::new( - format!("You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."), - ) - .size(LabelSize::Small).color(Color::Muted), - ) - .into_any() - } else { - v_flex() - .size_full() - .gap_1() - .child( - ConfiguredApiCard::new("mistral-reset-key", configured_card_label) - .disabled(env_var_set) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) - .when(env_var_set, |this| { - this.tooltip_label(format!( - "To reset your API key, \ - unset the {API_KEY_ENV_VAR_NAME} environment variable." - )) - }), - ) - .into_any() - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index 7bbc2ec18cd..f0166c6afb1 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -4,7 +4,7 @@ use credentials_provider::CredentialsProvider; use fs::Fs; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; use futures::{Stream, TryFutureExt, stream}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt}; +use gpui::{App, AsyncApp, Context, Entity, Task, TaskExt}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ ApiKeyState, AuthenticateError, DisabledReason, EnvVar, IconOrSvg, InlineDescription, @@ -12,7 +12,8 @@ use language_model::{ LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelRequestTool, LanguageModelToolChoice, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, - RateLimiter, Role, StopReason, TokenUsage, env_var, + ProviderSettingsView, RateLimiter, Role, StopReason, SubPageProviderSettings, TokenUsage, + env_var, }; use menu; use ollama::{ @@ -276,12 +277,6 @@ impl LanguageModelProvider for OllamaLanguageModelProvider { IconOrSvg::Icon(IconName::AiOllama) } - fn inline_description(&self, _cx: &App) -> Option { - Some(InlineDescription::Text( - "Run local models on your machine with Ollama.".into(), - )) - } - fn default_model(&self, _: &App) -> Option> { // We shouldn't try to select default model, because it might lead to a load call for an unloaded model. // In a constrained environment where user might not have enough resources it'll be a bad UX to select something @@ -341,20 +336,17 @@ impl LanguageModelProvider for OllamaLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { + fn settings_view(&self, _cx: &mut App) -> Option { let state = self.state.clone(); - cx.new(|cx| ConfigurationView::new(state, window, cx)) - .into() - } - - fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) + Some(ProviderSettingsView::SubPage( + SubPageProviderSettings::new(move |window, cx| { + cx.new(|cx| ConfigurationView::new(state.clone(), window, cx)) + .into() + }) + .description(InlineDescription::Text( + "Run local models on your machine with Ollama.".into(), + )), + )) } } diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 87be0e2751b..c2c74a8553b 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -2,7 +2,7 @@ use anyhow::Result; use collections::BTreeMap; use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, FastModeConfirmation, IconOrSvg, @@ -10,20 +10,17 @@ use language_model::{ LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, OPEN_AI_PROVIDER_ID, OPEN_AI_PROVIDER_NAME, - RateLimiter, env_var, + ProviderSettingsView, RateLimiter, env_var, }; -use menu; use open_ai::{ - OPEN_AI_API_URL, ResponseStreamEvent, + ResponseStreamEvent, responses::{Request as ResponseRequest, StreamEvent as ResponsesStreamEvent, stream_response}, stream_completion, }; use settings::{OpenAiAvailableModel as AvailableModel, Settings, SettingsStore}; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; -use ui_input::InputField; -use util::ResultExt; +use ui::IconName; pub use open_ai::completion::{ ChatCompletionMaxTokensParameter, OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, @@ -204,34 +201,19 @@ impl LanguageModelProvider for OpenAiLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) - .into() - } - - fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) - } - - fn api_key_configuration(&self, cx: &App) -> Option { + fn settings_view(&self, cx: &mut App) -> Option { let state = self.state.read(cx); - Some(ApiKeyConfiguration { - has_key: state.api_key_state.has_key(), - is_from_env_var: state.api_key_state.is_from_env_var(), - env_var_name: state.api_key_state.env_var_name().clone(), - api_key_url: "https://platform.openai.com/api-keys".into(), - }) + Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new( + state.api_key_state.has_key(), + state.api_key_state.is_from_env_var(), + state.api_key_state.env_var_name().clone(), + "https://platform.openai.com/api-keys".into(), + ))) } - fn set_api_key(&self, key: String, cx: &mut App) -> Task> { + fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> { self.state - .update(cx, |state, cx| state.set_api_key(Some(key), cx)) + .update(cx, |state, cx| state.set_api_key(api_key, cx)) } fn fast_mode_confirmation(&self, _cx: &App) -> Option { @@ -590,183 +572,3 @@ impl LanguageModel for OpenAiLanguageModel { } } } - -struct ConfigurationView { - api_key_editor: Entity, - state: Entity, - load_credentials_task: Option>, -} - -impl ConfigurationView { - fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { - let api_key_editor = cx.new(|cx| { - InputField::new( - window, - cx, - "sk-000000000000000000000000000000000000000000000000", - ) - }); - - cx.observe(&state, |_, _, cx| { - cx.notify(); - }) - .detach(); - - let load_credentials_task = Some(cx.spawn_in(window, { - let state = state.clone(); - async move |this, cx| { - if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) { - // We don't log an error, because "not signed in" is also an error. - let _ = task.await; - } - this.update(cx, |this, cx| { - this.load_credentials_task = None; - cx.notify(); - }) - .log_err(); - } - })); - - Self { - api_key_editor, - state, - load_credentials_task, - } - } - - fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); - if api_key.is_empty() { - return; - } - - // url changes can cause the editor to be displayed again - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(Some(api_key), cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.api_key_editor - .update(cx, |input, cx| input.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(None, cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn should_render_editor(&self, cx: &mut Context) -> bool { - !self.state.read(cx).is_authenticated() - } -} - -impl Render for ConfigurationView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); - let configured_card_label = if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = OpenAiLanguageModelProvider::api_url(cx); - if api_url == OPEN_AI_API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", api_url) - } - }; - - let api_key_section = if self.should_render_editor(cx) { - v_flex() - .on_action(cx.listener(Self::save_api_key)) - .child(Label::new("To use Zed's agent with OpenAI, you need to add an API key. Follow these steps:")) - .child( - List::new() - .child( - ListBulletItem::new("") - .child(Label::new("Create one by visiting")) - .child(ButtonLink::new("OpenAI's console", "https://platform.openai.com/api-keys")) - ) - .child( - ListBulletItem::new("Ensure your OpenAI account has credits") - ) - .child( - ListBulletItem::new("Paste your API key below and hit enter to start using the agent") - ), - ) - .child(self.api_key_editor.clone()) - .child( - Label::new(format!( - "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed." - )) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - Label::new( - "Note that having a subscription for another service like GitHub Copilot won't work.", - ) - .size(LabelSize::Small).color(Color::Muted), - ) - .into_any_element() - } else { - ConfiguredApiCard::new("openai-reset-key", configured_card_label) - .disabled(env_var_set) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) - .when(env_var_set, |this| { - this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable.")) - }) - .into_any_element() - }; - - let compatible_api_section = h_flex() - .mt_1p5() - .gap_0p5() - .flex_wrap() - .when(self.should_render_editor(cx), |this| { - this.pt_1p5() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - }) - .child( - h_flex() - .gap_2() - .child( - Icon::new(IconName::Info) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child(Label::new("Zed also supports OpenAI-compatible models.")), - ) - .child( - Button::new("docs", "Learn More") - .end_icon( - Icon::new(IconName::ArrowUpRight) - .size(IconSize::Small) - .color(Color::Muted), - ) - .on_click(move |_, _window, cx| { - cx.open_url("https://zed.dev/docs/ai/llm-providers#openai-api-compatible") - }), - ); - - if self.load_credentials_task.is_some() { - div().child(Label::new("Loading credentials…")).into_any() - } else { - v_flex() - .size_full() - .child(api_key_section) - .child(compatible_api_section) - .into_any() - } - } -} diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index e0c6f1f16ea..e69e847eab9 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -1,14 +1,14 @@ use anyhow::Result; use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AppContext, AsyncApp, Entity, Task, Window}; +use gpui::{App, AppContext, AsyncApp, Entity, Task}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, - LanguageModelToolSchemaFormat, RateLimiter, + LanguageModelToolSchemaFormat, ProviderSettingsView, RateLimiter, SubPageProviderSettings, }; use open_ai::{ ResponseStreamEvent, @@ -144,32 +144,27 @@ impl LanguageModelProvider for OpenAiCompatibleLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| { - ApiCompatibleProviderConfigurationView::new( - self.state.clone(), - "OpenAI", - API_KEY_PLACEHOLDER, - window, - cx, - ) - }) - .into() + fn settings_view(&self, _cx: &mut App) -> Option { + let state = self.state.clone(); + Some(ProviderSettingsView::SubPage(SubPageProviderSettings::new( + move |window, cx| { + cx.new(|cx| { + ApiCompatibleProviderConfigurationView::new( + state.clone(), + "OpenAI", + API_KEY_PLACEHOLDER, + window, + cx, + ) + }) + .into() + }, + ))) } - fn set_api_key(&self, key: String, cx: &mut App) -> Task> { + fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> { self.state - .update(cx, |state, cx| state.set_api_key(Some(key), cx)) - } - - fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) + .update(cx, |state, cx| state.set_api_key(api_key, cx)) } } diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index f41e04972d0..fd5221c9b7c 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -2,7 +2,7 @@ use anyhow::Result; use collections::HashMap; use credentials_provider::CredentialsProvider; use futures::{FutureExt, Stream, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, @@ -10,7 +10,7 @@ use language_model::{ LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolSchemaFormat, LanguageModelToolUse, - MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var, + MessageContent, ProviderSettingsView, RateLimiter, Role, StopReason, TokenUsage, env_var, }; use open_router::{ Model, ModelMode as OpenRouterModelMode, OPEN_ROUTER_API_URL, ResponseStreamEvent, list_models, @@ -18,9 +18,7 @@ use open_router::{ use settings::{OpenRouterAvailableModel as AvailableModel, Settings, SettingsStore}; use std::pin::Pin; use std::sync::{Arc, LazyLock}; -use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; -use ui_input::InputField; -use util::ResultExt; +use ui::IconName; use language_model::util::{fix_streamed_json, parse_tool_arguments}; @@ -259,34 +257,19 @@ impl LanguageModelProvider for OpenRouterLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) - .into() - } - - fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) - } - - fn api_key_configuration(&self, cx: &App) -> Option { + fn settings_view(&self, cx: &mut App) -> Option { let state = self.state.read(cx); - Some(ApiKeyConfiguration { - has_key: state.api_key_state.has_key(), - is_from_env_var: state.api_key_state.is_from_env_var(), - env_var_name: state.api_key_state.env_var_name().clone(), - api_key_url: "https://openrouter.ai/keys".into(), - }) + Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new( + state.api_key_state.has_key(), + state.api_key_state.is_from_env_var(), + state.api_key_state.env_var_name().clone(), + "https://openrouter.ai/keys".into(), + ))) } - fn set_api_key(&self, key: String, cx: &mut App) -> Task> { + fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> { self.state - .update(cx, |state, cx| state.set_api_key(Some(key), cx)) + .update(cx, |state, cx| state.set_api_key(api_key, cx)) } } @@ -886,141 +869,6 @@ struct RawToolCall { thought_signature: Option, } -struct ConfigurationView { - api_key_editor: Entity, - state: Entity, - load_credentials_task: Option>, -} - -impl ConfigurationView { - fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { - let api_key_editor = cx.new(|cx| { - InputField::new( - window, - cx, - "sk_or_000000000000000000000000000000000000000000000000", - ) - }); - - cx.observe(&state, |_, _, cx| { - cx.notify(); - }) - .detach(); - - let load_credentials_task = Some(cx.spawn_in(window, { - let state = state.clone(); - async move |this, cx| { - if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) { - let _ = task.await; - } - - this.update(cx, |this, cx| { - this.load_credentials_task = None; - cx.notify(); - }) - .log_err(); - } - })); - - Self { - api_key_editor, - state, - load_credentials_task, - } - } - - fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); - if api_key.is_empty() { - return; - } - - // url changes can cause the editor to be displayed again - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(Some(api_key), cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(None, cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn should_render_editor(&self, cx: &mut Context) -> bool { - !self.state.read(cx).is_authenticated() - } -} - -impl Render for ConfigurationView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); - let configured_card_label = if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = OpenRouterLanguageModelProvider::api_url(cx); - if api_url == OPEN_ROUTER_API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", api_url) - } - }; - - if self.load_credentials_task.is_some() { - div() - .child(Label::new("Loading credentials...")) - .into_any_element() - } else if self.should_render_editor(cx) { - v_flex() - .size_full() - .on_action(cx.listener(Self::save_api_key)) - .child(Label::new("To use Zed's agent with OpenRouter, you need to add an API key. Follow these steps:")) - .child( - List::new() - .child( - ListBulletItem::new("") - .child(Label::new("Create an API key by visiting")) - .child(ButtonLink::new("OpenRouter's console", "https://openrouter.ai/keys")) - ) - .child(ListBulletItem::new("Ensure your OpenRouter account has credits") - ) - .child(ListBulletItem::new("Paste your API key below and hit enter to start using the assistant") - ), - ) - .child(self.api_key_editor.clone()) - .child( - Label::new( - format!("You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."), - ) - .size(LabelSize::Small).color(Color::Muted), - ) - .into_any_element() - } else { - ConfiguredApiCard::new("openrouter-reset-key", configured_card_label) - .disabled(env_var_set) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) - .when(env_var_set, |this| { - this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable.")) - }) - .into_any_element() - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 2b09032ca51..20038e61234 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -3,7 +3,7 @@ use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture, future::Shared}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; +use gpui::{App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::{ AsyncBody, CustomHeaders, HttpClient, Method, Request as HttpRequest, http::{HeaderName, HeaderValue}, @@ -13,7 +13,7 @@ use language_model::{ LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, ProviderConfigurationView, RateLimiter, + LanguageModelToolChoice, ProviderSettingsView, RateLimiter, }; use open_ai::{ReasoningEffort, responses::stream_response}; use rand::RngCore as _; @@ -157,10 +157,6 @@ impl OpenAiSubscribedProvider { }); } - fn sign_out(&self, cx: &mut App) -> Task> { - do_sign_out(&self.state.downgrade(), cx) - } - fn create_language_model(&self, model: ChatGptModel) -> Arc { Arc::new(OpenAiSubscribedLanguageModel { id: LanguageModelId::from(model.id().to_string()), @@ -243,71 +239,48 @@ impl LanguageModelProvider for OpenAiSubscribedProvider { } } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - _window: &mut Window, - cx: &mut App, - ) -> AnyView { - let state = self.state.clone(); - let http_client = self.http_client.clone(); - cx.new(|_cx| ConfigurationView { - state, - http_client, - compact: false, - }) - .into() - } - - fn configuration_view_v2( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - _window: &mut Window, - cx: &mut App, - ) -> ProviderConfigurationView { - let state = self.state.clone(); - let http_client = self.http_client.clone(); - - ProviderConfigurationView::Inline { - view: cx - .new(|_cx| ConfigurationView { - state, - http_client, - compact: true, - }) - .into(), - } - } - - fn inline_title(&self, cx: &App) -> Option { - if self.state.read(cx).is_authenticated() { + fn settings_view(&self, cx: &mut App) -> Option { + let is_authenticated = self.state.read(cx).is_authenticated(); + let title = if is_authenticated { None } else { Some("Configure ChatGPT".into()) - } - } - - fn inline_description(&self, cx: &App) -> Option { - if self.state.read(cx).is_authenticated() { + }; + let description = if is_authenticated { None } else { Some(InlineDescription::Text(SUBSCRIPTION_DESCRIPTION.into())) - } - } + }; - fn reset_credentials(&self, cx: &mut App) -> Task> { - self.sign_out(cx) + Some(ProviderSettingsView::Inline( + language_model::InlineProviderSettings { + title, + description, + create_view: Arc::new({ + let state = self.state.clone(); + let http_client = self.http_client.clone(); + move |_window, cx| { + cx.new(|_cx| ConfigurationView { + state: state.clone(), + http_client: http_client.clone(), + compact: true, + }) + .into() + } + }), + }, + )) } fn authentication_error_message(&self) -> SharedString { "Your ChatGPT subscription session is invalid or has expired. \ - Sign in again via the Agent Panel settings to continue." + Sign in again via Settings > AI > LLM Providers to continue." .into() } fn missing_credentials_error_message(&self) -> SharedString { "You are not signed in to your ChatGPT account. \ - Sign in via the Agent Panel settings to continue." + Sign in via Settings > AI > LLM Providers to continue." .into() } diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index f75c71e425b..94689cc0d66 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -3,14 +3,15 @@ use collections::BTreeMap; use credentials_provider::CredentialsProvider; use fs::Fs; use futures::{FutureExt, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; +use gpui::{App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; use http_client::{AsyncBody, CustomHeaders, HttpClient, http}; use language_model::{ ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, InlineDescription, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, RateLimiter, ReasoningEffort, env_var, + LanguageModelToolChoice, ProviderSettingsView, RateLimiter, ReasoningEffort, + SubPageProviderSettings, env_var, }; use opencode::{ApiProtocol, OPENCODE_API_URL, OpenCodeSubscription}; pub use settings::OpenCodeAvailableModel as AvailableModel; @@ -200,12 +201,6 @@ impl LanguageModelProvider for OpenCodeLanguageModelProvider { IconOrSvg::Icon(IconName::AiOpenCode) } - fn inline_description(&self, _cx: &App) -> Option { - Some(InlineDescription::Text( - "To use OpenCode models in Zed, you need an API key.".into(), - )) - } - fn default_model(&self, cx: &App) -> Option> { if Self::subscription_enabled(OpenCodeSubscription::Go, cx) { // If both Go and Zen are enabled, prefer Go since it's not pay-as-you-go @@ -311,19 +306,17 @@ impl LanguageModelProvider for OpenCodeLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) - .into() - } - - fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) + fn settings_view(&self, _cx: &mut App) -> Option { + let state = self.state.clone(); + Some(ProviderSettingsView::SubPage( + SubPageProviderSettings::new(move |window, cx| { + cx.new(|cx| ConfigurationView::new(state.clone(), window, cx)) + .into() + }) + .description(InlineDescription::Text( + "To use OpenCode models in Zed, you need an API key.".into(), + )), + )) } } diff --git a/crates/language_models/src/provider/vercel_ai_gateway.rs b/crates/language_models/src/provider/vercel_ai_gateway.rs index dcceffe22e7..212223eb851 100644 --- a/crates/language_models/src/provider/vercel_ai_gateway.rs +++ b/crates/language_models/src/provider/vercel_ai_gateway.rs @@ -2,7 +2,7 @@ use anyhow::Result; use collections::BTreeMap; use credentials_provider::CredentialsProvider; use futures::{AsyncReadExt, FutureExt, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task}; use http_client::{ AsyncBody, CustomHeaders, HttpClient, Method, Request as HttpRequest, RequestBuilderExt, http, }; @@ -11,7 +11,7 @@ use language_model::{ LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, - LanguageModelToolSchemaFormat, RateLimiter, env_var, + LanguageModelToolSchemaFormat, ProviderSettingsView, RateLimiter, env_var, }; use open_ai::ResponseStreamEvent; use serde::Deserialize; @@ -19,9 +19,7 @@ pub use settings::OpenAiCompatibleModelCapabilities as ModelCapabilities; pub use settings::VercelAiGatewayAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::sync::{Arc, LazyLock}; -use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; -use ui_input::InputField; -use util::ResultExt; +use ui::IconName; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("vercel_ai_gateway"); const PROVIDER_NAME: LanguageModelProviderName = @@ -246,36 +244,20 @@ impl LanguageModelProvider for VercelAiGatewayLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) - .into() - } - - fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) - } - - fn api_key_configuration(&self, cx: &App) -> Option { + fn settings_view(&self, cx: &mut App) -> Option { let state = self.state.read(cx); - Some(ApiKeyConfiguration { - has_key: state.api_key_state.has_key(), - is_from_env_var: state.api_key_state.is_from_env_var(), - env_var_name: state.api_key_state.env_var_name().clone(), - api_key_url: - "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys&title=Go+to+AI+Gateway" - .into(), - }) + Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new( + state.api_key_state.has_key(), + state.api_key_state.is_from_env_var(), + state.api_key_state.env_var_name().clone(), + "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys&title=Go+to+AI+Gateway" + .into(), + ))) } - fn set_api_key(&self, key: String, cx: &mut App) -> Task> { + fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> { self.state - .update(cx, |state, cx| state.set_api_key(Some(key), cx)) + .update(cx, |state, cx| state.set_api_key(api_key, cx)) } } @@ -618,131 +600,3 @@ async fn list_models( Ok(models) } - -struct ConfigurationView { - api_key_editor: Entity, - state: Entity, - load_credentials_task: Option>, -} - -impl ConfigurationView { - fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { - let api_key_editor = - cx.new(|cx| InputField::new(window, cx, "vck_000000000000000000000000000")); - - cx.observe(&state, |_, _, cx| cx.notify()).detach(); - - let load_credentials_task = Some(cx.spawn_in(window, { - let state = state.clone(); - async move |this, cx| { - if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) { - let _ = task.await; - } - this.update(cx, |this, cx| { - this.load_credentials_task = None; - cx.notify(); - }) - .log_err(); - } - })); - - Self { - api_key_editor, - state, - load_credentials_task, - } - } - - fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); - if api_key.is_empty() { - return; - } - - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(Some(api_key), cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(None, cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn should_render_editor(&self, cx: &Context) -> bool { - !self.state.read(cx).is_authenticated() - } -} - -impl Render for ConfigurationView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); - let configured_card_label = if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = VercelAiGatewayLanguageModelProvider::api_url(cx); - if api_url == API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", api_url) - } - }; - - if self.load_credentials_task.is_some() { - div().child(Label::new("Loading credentials...")).into_any() - } else if self.should_render_editor(cx) { - v_flex() - .size_full() - .on_action(cx.listener(Self::save_api_key)) - .child(Label::new( - "To use Zed's agent with Vercel AI Gateway, you need to add an API key. Follow these steps:", - )) - .child( - List::new() - .child( - ListBulletItem::new("") - .child(Label::new("Create an API key in")) - .child(ButtonLink::new( - "Vercel AI Gateway's console", - "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys&title=Go+to+AI+Gateway", - )), - ) - .child(ListBulletItem::new( - "Paste your API key below and hit enter to start using the assistant", - )), - ) - .child(self.api_key_editor.clone()) - .child( - Label::new(format!( - "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed.", - )) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .into_any_element() - } else { - ConfiguredApiCard::new("vercel-reset-key", configured_card_label) - .disabled(env_var_set) - .when(env_var_set, |this| { - this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable.")) - }) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) - .into_any_element() - } - } -} diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index 92121f2e5ff..4556e439a39 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -2,23 +2,22 @@ use anyhow::Result; use collections::BTreeMap; use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt, Window}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, SharedString, Task}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, env_var, + LanguageModelToolChoice, LanguageModelToolSchemaFormat, ProviderSettingsView, RateLimiter, + env_var, }; use open_ai::ResponseStreamEvent; pub use settings::XaiAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; -use ui_input::InputField; -use util::ResultExt; +use ui::IconName; use x_ai::XAI_API_URL; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("x_ai"); @@ -193,34 +192,19 @@ impl LanguageModelProvider for XAiLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) - .into() - } - - fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state - .update(cx, |state, cx| state.set_api_key(None, cx)) - } - - fn api_key_configuration(&self, cx: &App) -> Option { + fn settings_view(&self, cx: &mut App) -> Option { let state = self.state.read(cx); - Some(ApiKeyConfiguration { - has_key: state.api_key_state.has_key(), - is_from_env_var: state.api_key_state.is_from_env_var(), - env_var_name: state.api_key_state.env_var_name().clone(), - api_key_url: "https://console.x.ai/team/default/api-keys".into(), - }) + Some(ProviderSettingsView::ApiKey(ApiKeyConfiguration::new( + state.api_key_state.has_key(), + state.api_key_state.is_from_env_var(), + state.api_key_state.env_var_name().clone(), + "https://console.x.ai/team/default/api-keys".into(), + ))) } - fn set_api_key(&self, key: String, cx: &mut App) -> Task> { + fn set_api_key(&self, api_key: Option, cx: &mut App) -> Task> { self.state - .update(cx, |state, cx| state.set_api_key(Some(key), cx)) + .update(cx, |state, cx| state.set_api_key(api_key, cx)) } } @@ -448,87 +432,6 @@ impl LanguageModel for XAiLanguageModel { } } -struct ConfigurationView { - api_key_editor: Entity, - state: Entity, - load_credentials_task: Option>, -} - -impl ConfigurationView { - fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { - let api_key_editor = cx.new(|cx| { - InputField::new( - window, - cx, - "xai-0000000000000000000000000000000000000000000000000", - ) - .label("API key") - }); - - cx.observe(&state, |_, _, cx| { - cx.notify(); - }) - .detach(); - - let load_credentials_task = Some(cx.spawn_in(window, { - let state = state.clone(); - async move |this, cx| { - if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) { - // We don't log an error, because "not signed in" is also an error. - let _ = task.await; - } - this.update(cx, |this, cx| { - this.load_credentials_task = None; - cx.notify(); - }) - .log_err(); - } - })); - - Self { - api_key_editor, - state, - load_credentials_task, - } - } - - fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); - if api_key.is_empty() { - return; - } - - // url changes can cause the editor to be displayed again - self.api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(Some(api_key), cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.api_key_editor - .update(cx, |input, cx| input.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(None, cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn should_render_editor(&self, cx: &mut Context) -> bool { - !self.state.read(cx).is_authenticated() - } -} - #[cfg(test)] mod tests { use super::*; @@ -578,64 +481,3 @@ mod tests { ); } } - -impl Render for ConfigurationView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); - let configured_card_label = if env_var_set { - format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") - } else { - let api_url = XAiLanguageModelProvider::api_url(cx); - if api_url == XAI_API_URL { - "API key configured".to_string() - } else { - format!("API key configured for {}", api_url) - } - }; - - let api_key_section = if self.should_render_editor(cx) { - v_flex() - .on_action(cx.listener(Self::save_api_key)) - .child(Label::new("To use Zed's agent with xAI, you need to add an API key. Follow these steps:")) - .child( - List::new() - .child( - ListBulletItem::new("") - .child(Label::new("Create one by visiting")) - .child(ButtonLink::new("xAI console", "https://console.x.ai/team/default/api-keys")) - ) - .child( - ListBulletItem::new("Paste your API key below and hit enter to start using the agent") - ), - ) - .child(self.api_key_editor.clone()) - .child( - Label::new(format!( - "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed." - )) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - Label::new("Note that xAI is a custom OpenAI-compatible provider.") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .into_any_element() - } else { - ConfiguredApiCard::new("xai-reset-key", configured_card_label) - .disabled(env_var_set) - .when(env_var_set, |this| { - this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable.")) - }) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) - .into_any_element() - }; - - if self.load_credentials_task.is_some() { - div().child(Label::new("Loading credentials…")).into_any() - } else { - v_flex().size_full().child(api_key_section).into_any() - } - } -} diff --git a/crates/settings_ui/src/pages/llm_providers_page.rs b/crates/settings_ui/src/pages/llm_providers_page.rs index f888b031eb8..bac9b7abb06 100644 --- a/crates/settings_ui/src/pages/llm_providers_page.rs +++ b/crates/settings_ui/src/pages/llm_providers_page.rs @@ -3,9 +3,8 @@ use std::{collections::HashSet, sync::Arc}; use editor::Editor; use gpui::{AnyView, Entity, Focusable as _, ScrollHandle, prelude::*}; use language_model::{ - ApiKeyConfiguration, ConfigurationViewTargetAgent, IconOrSvg, InlineDescription, - LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, - ProviderConfigurationView, + ApiKeyConfiguration, CreateProviderSettingsView, IconOrSvg, InlineDescription, + LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, ProviderSettingsView, }; use settings::{ @@ -149,14 +148,29 @@ fn render_provider_section( let provider_id = provider.id(); let provider_name = provider.name().0; - let body = if let Some(config) = provider.api_key_configuration(cx) { - render_api_key_providers_item(settings_window, provider, provider_name.clone(), config, cx) - } else { - match get_or_create_configuration_view(settings_window, &provider_id, provider, window, cx) - { - ProviderConfigurationView::Inline { view } => render_inline_body(provider, view, cx), - ProviderConfigurationView::SubPage(_) => render_subpage_item(provider, cx), + let body = match provider.settings_view(cx) { + Some(ProviderSettingsView::ApiKey(config)) => { + render_api_key_providers_item(provider, provider_name.clone(), config, cx) } + Some(ProviderSettingsView::Inline(settings)) => { + let view = get_or_create_configuration_view( + settings_window, + &provider_id, + settings.create_view, + window, + cx, + ); + render_inline_body( + provider_name.clone(), + settings.title, + settings.description, + view, + ) + } + Some(ProviderSettingsView::SubPage(settings)) => { + render_subpage_item(provider, settings.description, cx) + } + None => div().into_any_element(), }; v_flex() @@ -196,18 +210,16 @@ fn render_provider_header( } fn render_api_key_providers_item( - _settings_window: &SettingsWindow, provider: &Arc, provider_name: SharedString, config: ApiKeyConfiguration, _cx: &mut Context, ) -> AnyElement { - let ApiKeyConfiguration { - has_key, - is_from_env_var, - env_var_name, - api_key_url, - } = config; + let provider_id = provider.id(); + let has_key = config.has_key; + let is_from_env_var = config.is_from_env_var; + let env_var_name = config.env_var_name; + let api_key_url = config.api_key_url; if has_key { let configured_label = if is_from_env_var { @@ -215,7 +227,7 @@ fn render_api_key_providers_item( } else { "API Key Configured" }; - let button_id = format!("reset-api-key-{}", provider.id().0); + let button_id = format!("reset-api-key-{}", provider_id.0); let card = ConfiguredApiCard::new(button_id, configured_label) .button_label("Reset Key") @@ -229,7 +241,7 @@ fn render_api_key_providers_item( .on_click({ let provider = provider.clone(); move |_, _, cx| { - provider.reset_credentials(cx).detach_and_log_err(cx); + provider.set_api_key(None, cx).detach_and_log_err(cx); } }) .into_any_element(); @@ -237,7 +249,7 @@ fn render_api_key_providers_item( return v_flex().gap_2().child(card).into_any_element(); } - let input_id = format!("{}-api-key-input", provider.id().0); + let input_id = format!("{}-api-key-input", provider_id.0); let aria_label = format!("{provider_name} API Key"); v_flex() @@ -299,7 +311,7 @@ fn render_api_key_providers_item( let provider = provider.clone(); move |api_key, _window, cx| { if let Some(key) = api_key.filter(|key| !key.is_empty()) { - provider.set_api_key(key, cx).detach_and_log_err(cx); + provider.set_api_key(Some(key), cx).detach_and_log_err(cx); } } }), @@ -309,14 +321,11 @@ fn render_api_key_providers_item( } fn render_inline_body( - provider: &Arc, + provider_name: SharedString, + title: Option, + description: Option, view: AnyView, - cx: &mut Context, ) -> AnyElement { - let provider_name = provider.name().0; - let title = provider.inline_title(cx); - let description = provider.inline_description(cx); - if title.is_none() && description.is_none() { return v_flex() .pt_1() @@ -348,11 +357,11 @@ fn render_inline_body( fn render_subpage_item( provider: &Arc, + description: Option, cx: &mut Context, ) -> AnyElement { let provider_id = provider.id(); let provider_name = provider.name().0; - let description = provider.inline_description(cx); h_flex() .pt_2p5() @@ -449,18 +458,19 @@ fn render_provider_config_sub_page( return div().into_any_element(); }; - // A provider routed to a sub-page always provides a `SubPage` view; fall - // back to whatever view it returns otherwise. - let view = match get_or_create_configuration_view( - settings_window, - &provider_id, - &provider, - window, - cx, - ) { - ProviderConfigurationView::Inline { view, .. } - | ProviderConfigurationView::SubPage(view) => view, + let Some(create_view) = + provider + .settings_view(cx) + .and_then(|settings_view| match settings_view { + ProviderSettingsView::Inline(settings) => Some(settings.create_view), + ProviderSettingsView::SubPage(settings) => Some(settings.create_view), + ProviderSettingsView::ApiKey(_) => None, + }) + else { + return div().into_any_element(); }; + let view = + get_or_create_configuration_view(settings_window, &provider_id, create_view, window, cx); v_flex() .id("provider-config-sub-page") @@ -477,10 +487,10 @@ fn render_provider_config_sub_page( fn get_or_create_configuration_view( settings_window: &SettingsWindow, provider_id: &LanguageModelProviderId, - provider: &Arc, + create_view: CreateProviderSettingsView, window: &mut Window, cx: &mut Context, -) -> ProviderConfigurationView { +) -> AnyView { if let Some(view) = settings_window .provider_configuration_views .get(provider_id) @@ -488,7 +498,7 @@ fn get_or_create_configuration_view( return view.clone(); } - let view = provider.configuration_view_v2(ConfigurationViewTargetAgent::ZedAgent, window, cx); + let view = create_view(window, cx); // Store the view for future renders by deferring a mutation let provider_id = provider_id.clone(); @@ -1146,7 +1156,7 @@ fn save_llm_provider_form( let provider = LanguageModelRegistry::read_global(cx) .provider(&provider_id) .ok_or_else(|| anyhow::anyhow!("Provider was not registered"))?; - anyhow::Ok(provider.set_api_key(api_key, cx)) + anyhow::Ok(provider.set_api_key(Some(api_key), cx)) })??; set_api_key.await?; diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 34a2ac271d4..35f816d649b 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -948,10 +948,9 @@ pub struct SettingsWindow { pub(crate) regex_validation_error: Option, pub(crate) sandbox_host_validation_error: Option, last_copied_link_path: Option<&'static str>, - /// Cached configuration views per provider, created lazily. Holds the - /// provider's chosen presentation ([`Inline`] or [`SubPage`]). + /// Cached configuration views per provider, created lazily. pub(crate) provider_configuration_views: - HashMap, + HashMap, /// The provider whose configuration sub-page is currently open, if any. pub(crate) configuring_provider: Option, /// Directory path of the skill whose share link was most recently copied, diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 9d47f272406..8b2d0d45df7 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -539,7 +539,7 @@ pub mod agent { actions!( agent, [ - /// Opens the agent settings panel. + /// Opens the agent settings UI. #[action(deprecated_aliases = ["agent::OpenConfiguration"])] OpenSettings, /// Opens the agent onboarding modal. From e1ec575d3f78968c8cbc4168cdda40cac5e2aa8c Mon Sep 17 00:00:00 2001 From: saberoueslati Date: Thu, 2 Jul 2026 15:38:04 +0100 Subject: [PATCH 031/197] editor: Update gutter hover tooltip on modifier changes (#58880) ## Context The gutter hover button can represent either adding a breakpoint or adding a bookmark depending on whether the secondary modifier is pressed. Previously, the visible tooltip did not update when the modifier changed while the popover was already open. This moves the gutter hover intent into a shared state used by both the button and tooltip, so the label, metadata, icon, color, click action, and displayed keybinding all stay in sync. Closes #58821 ## How to Review `crates/editor/src/editor.rs` adds a shared gutter hover intent type and a custom tooltip view that reads the current modifier state while rendering. Review that bookmark and breakpoint behavior map consistently across the button icon/color, click action, tooltip label, metadata, and keybinding. `crates/editor/src/editor_tests.rs` adds a regression test covering the tooltip intent selection with and without the secondary modifier. Manual test after the fix below : [Screencast from 2026-06-09 01-30-23.webm](https://github.com/user-attachments/assets/ef465eb9-e659-44da-93a6-a511c84e9401) ## 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 - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed the breakpoint/bookmark gutter popover not updating when modifier keys change. --------- Co-authored-by: Danilo Leal --- crates/editor/src/editor.rs | 168 +++++++++++++++++++----------- crates/editor/src/editor_tests.rs | 39 +++++++ 2 files changed, 147 insertions(+), 60 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 36b5209a5c0..913ab20c4c1 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -254,9 +254,8 @@ use theme::{ }; use theme_settings::{ThemeSettings, observe_buffer_font_size_adjustment}; use ui::{ - Avatar, ButtonSize, ButtonStyle, ContextMenu, Disclosure, IconButton, IconButtonShape, - IconName, IconSize, Indicator, Key, Tooltip, h_flex, prelude::*, scrollbars::ScrollbarAutoHide, - utils::WithRemSize, + Avatar, ContextMenu, Disclosure, IconButtonShape, Indicator, Key, KeyBinding, Tooltip, + prelude::*, scrollbars::ScrollbarAutoHide, tooltip_container, utils::WithRemSize, }; use ui_input::ErasedEditor; use util::{RangeExt, ResultExt, TryFutureExt, maybe, post_inc}; @@ -1637,6 +1636,97 @@ pub struct RewrapOptions { pub line_length: Option, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum GutterButtonIntent { + SetBookmark, + SetBreakpoint, +} + +impl GutterButtonIntent { + fn as_str(&self) -> &'static str { + match self { + Self::SetBookmark => "Set Bookmark", + Self::SetBreakpoint => "Set Breakpoint", + } + } + + fn icon(&self) -> ui::IconName { + match self { + Self::SetBookmark => ui::IconName::Bookmark, + Self::SetBreakpoint => ui::IconName::DebugBreakpoint, + } + } + + fn color(&self) -> Color { + match self { + Self::SetBookmark => Color::Info, + Self::SetBreakpoint => Color::Hint, + } + } + + fn action(&self) -> &'static dyn Action { + match self { + Self::SetBookmark => &ToggleBookmark, + Self::SetBreakpoint => &ToggleBreakpoint, + } + } +} + +struct GutterButtonTooltip { + primary: GutterButtonIntent, + secondary: GutterButtonIntent, + focus_handle: FocusHandle, +} + +impl GutterButtonTooltip { + fn active_intent(&self, modifiers: Modifiers) -> GutterButtonIntent { + if modifiers.secondary() { + self.secondary + } else { + self.primary + } + } + + fn meta_text(&self, intent: GutterButtonIntent) -> String { + const RIGHT_CLICK_HINT: &str = "right-click for more options"; + + if self.primary == self.secondary { + return RIGHT_CLICK_HINT.to_string(); + } + let modifier_as_text = gpui::Keystroke { + modifiers: Modifiers::secondary_key(), + ..Default::default() + }; + let other = match intent { + GutterButtonIntent::SetBookmark => "breakpoint", + GutterButtonIntent::SetBreakpoint => "bookmark", + }; + format!("{modifier_as_text}-click to add a {other}\n{RIGHT_CLICK_HINT}") + } +} + +impl Render for GutterButtonTooltip { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let intent = self.active_intent(window.modifiers()); + let key_binding = KeyBinding::for_action_in(intent.action(), &self.focus_handle, cx); + let meta_text = self.meta_text(intent); + + tooltip_container(cx, move |this, _| { + this.child( + h_flex() + .justify_between() + .child(intent.as_str()) + .child(key_binding), + ) + .child( + Label::new(meta_text) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }) + } +} + impl Editor { pub fn single_line(window: &mut Window, cx: &mut Context) -> Self { let buffer = cx.new(|cx| Buffer::local("", cx)); @@ -4404,61 +4494,20 @@ impl Editor { window: &mut Window, cx: &mut Context, ) -> IconButton { - #[derive(Clone, Copy)] - enum Intent { - SetBookmark, - SetBreakpoint, - } - - impl Intent { - fn as_str(&self) -> &'static str { - match self { - Intent::SetBookmark => "Set bookmark", - Intent::SetBreakpoint => "Set breakpoint", - } - } - - fn icon(&self) -> ui::IconName { - match self { - Intent::SetBookmark => ui::IconName::Bookmark, - Intent::SetBreakpoint => ui::IconName::DebugBreakpoint, - } - } - - fn color(&self) -> Color { - match self { - Intent::SetBookmark => Color::Info, - Intent::SetBreakpoint => Color::Hint, - } - } - - fn secondary_and_options(&self) -> String { - let alt_as_text = gpui::Keystroke { - modifiers: Modifiers::secondary_key(), - ..Default::default() - }; - match self { - Intent::SetBookmark => format!( - "{alt_as_text}-click to add a breakpoint\nright-click for more options" - ), - Intent::SetBreakpoint => format!( - "{alt_as_text}-click to add a bookmark\nright-click for more options" - ), - } - } - } - let gutter_settings = EditorSettings::get_global(cx).gutter; let show_bookmarks = self.show_bookmarks.unwrap_or(gutter_settings.bookmarks); let show_breakpoints = self.show_breakpoints.unwrap_or(gutter_settings.breakpoints); let [primary, secondary] = match [show_breakpoints, show_bookmarks] { - [true, true] => [Intent::SetBreakpoint, Intent::SetBookmark], - [true, false] => [Intent::SetBreakpoint; 2], - [false, true] => [Intent::SetBookmark; 2], + [true, true] => [ + GutterButtonIntent::SetBreakpoint, + GutterButtonIntent::SetBookmark, + ], + [true, false] => [GutterButtonIntent::SetBreakpoint; 2], + [false, true] => [GutterButtonIntent::SetBookmark; 2], [false, false] => { log::error!("Trying to place gutter_hover without anything enabled!!"); - [Intent::SetBookmark; 2] + [GutterButtonIntent::SetBookmark; 2] } }; @@ -4485,8 +4534,8 @@ impl Editor { }; match intent { - Intent::SetBookmark => editor.toggle_bookmark_at_row(row, cx), - Intent::SetBreakpoint => editor.edit_breakpoint_at_anchor( + GutterButtonIntent::SetBookmark => editor.toggle_bookmark_at_row(row, cx), + GutterButtonIntent::SetBreakpoint => editor.edit_breakpoint_at_anchor( position, Breakpoint::new_standard(), BreakpointEditAction::Toggle, @@ -4500,13 +4549,12 @@ impl Editor { })) .when(!has_context_menu, |button| { button.tooltip(move |_window, cx| { - Tooltip::with_meta_in( - intent.as_str(), - Some(&ToggleBreakpoint), - intent.secondary_and_options(), - &focus_handle, - cx, - ) + cx.new(|_| GutterButtonTooltip { + primary, + secondary, + focus_handle: focus_handle.clone(), + }) + .into() }) }) } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 4acdae8d281..181a4d447ba 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -28678,6 +28678,45 @@ fn add_log_breakpoint_at_cursor( ); } +#[gpui::test] +fn test_gutter_button_tooltip_updates_intent_with_secondary_modifier(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let focus_handle = cx.update(|cx| cx.focus_handle()); + let tooltip = GutterButtonTooltip { + primary: GutterButtonIntent::SetBreakpoint, + secondary: GutterButtonIntent::SetBookmark, + focus_handle, + }; + + let primary_intent = tooltip.active_intent(Modifiers::none()); + assert_eq!(primary_intent, GutterButtonIntent::SetBreakpoint); + assert!(primary_intent.action().as_any().is::()); + + let secondary_intent = tooltip.active_intent(Modifiers::secondary_key()); + assert_eq!(secondary_intent, GutterButtonIntent::SetBookmark); + assert!(secondary_intent.action().as_any().is::()); + + // When both features are enabled, the meta text advertises the + // modifier-click alternative. + let meta = tooltip.meta_text(primary_intent); + assert!(meta.contains("-click to add a bookmark"), "got: {meta}"); + assert!(meta.contains("right-click for more options")); + let meta = tooltip.meta_text(secondary_intent); + assert!(meta.contains("-click to add a breakpoint"), "got: {meta}"); + + // When only one feature is enabled (primary == secondary), a + // modifier-click repeats the primary action, so the tooltip must not + // advertise it. + let single_feature_tooltip = GutterButtonTooltip { + primary: GutterButtonIntent::SetBreakpoint, + secondary: GutterButtonIntent::SetBreakpoint, + focus_handle: tooltip.focus_handle, + }; + let meta = single_feature_tooltip.meta_text(GutterButtonIntent::SetBreakpoint); + assert_eq!(meta, "right-click for more options"); +} + #[gpui::test] async fn test_breakpoint_toggling(cx: &mut TestAppContext) { init_test(cx, |_| {}); From 27a9e2057b71ef84a1dbe06fc51ac04c62f420d4 Mon Sep 17 00:00:00 2001 From: Ibrahim Khan <2005ibrahimkhan@gmail.com> Date: Thu, 2 Jul 2026 08:38:22 -0700 Subject: [PATCH 032/197] vim: Fix Helix cursor position when deleting to end of line (#59987) ## Why / What Fixes #58702. In Helix mode the block cursor is a one-character selection that can rest on the trailing newline at the end of a line. When you select to the end of a line (excluding the newline) and press `d`, Helix leaves the cursor on the newline. Zed instead clamped the cursor onto the character immediately to the left of the deleted selection. The shared `visual_delete` always re-clipped the post-deletion cursor at line ends (`set_clip_at_line_ends(true)`), which is correct for Vim normal mode but contradicts Helix mode, where `Vim::clip_at_line_ends()` is `false`. This also made Helix inconsistent with itself: deleting a whole line left the cursor on the newline, but deleting to the end of a non-empty line clamped it onto the previous character. The fix honors the active mode's clip policy, so Helix keeps the cursor on the newline. ## Testing - Added `test_delete_to_end_of_line_keeps_cursor_on_newline` reproducing #58702. - Corrected `test_helix_select_end_of_line`; its mid-line assertion had captured the pre-fix (clamped) cursor position. Its whole-line assertion already expected the cursor on the newline, so the test is now internally consistent. - `cargo test -p vim`: 540 passed, 0 failed. - Vim visual-mode delete is unchanged (`is_helix()` is false for Vim modes). Release Notes: - Fixed Helix mode placing the cursor on the wrong character after deleting a selection that ends at the end of a line. --------- Co-authored-by: dino --- crates/vim/src/helix.rs | 31 ++++++++++++++++++++++++++++--- crates/vim/src/visual.rs | 5 +++-- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index 2642221b31c..79c65b227bb 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -2341,6 +2341,31 @@ mod test { ); } + // Deleting a selection that ends at the last non-newline character should + // leave the cursor on the newline (matching Helix), not clamp it onto the + // character to the left of the selection. + #[gpui::test] + async fn test_delete_to_end_of_line_keeps_cursor_on_newline(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + cx.set_state( + indoc! {" + ab«cdefgˇ» + hij"}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("d"); + + cx.assert_state( + indoc! {" + abˇ + hij"}, + Mode::HelixNormal, + ); + } + // #[gpui::test] // async fn test_delete_character_end_of_buffer(cx: &mut gpui::TestAppContext) { // let mut cx = VimTestContext::new(cx, true).await; @@ -2985,11 +3010,11 @@ mod test { cx.simulate_keystrokes("v g l d"); cx.assert_state("ˇ\nfox jumps over", Mode::HelixNormal); - // same from the middle of a line — cursor lands on the last - // remaining character (the space) after delete + // same from the middle of a line — the cursor rests on the trailing + // newline, matching Helix and the whole-line case above. cx.set_state("The ˇquick brown\nfox jumps over", Mode::HelixNormal); cx.simulate_keystrokes("v g l d"); - cx.assert_state("Theˇ \nfox jumps over", Mode::HelixNormal); + cx.assert_state("The ˇ\nfox jumps over", Mode::HelixNormal); } #[gpui::test] diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index e715e98a655..e9c3f22c23c 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -695,8 +695,9 @@ impl Vim { } editor.delete_selections_with_linked_edits(window, cx); - // Fixup cursor position after the deletion - editor.set_clip_at_line_ends(true, cx); + // Fixup cursor position after the deletion. Helix keeps the + // cursor on the trailing newline, so only clip in Vim modes. + editor.set_clip_at_line_ends(!vim.mode.is_helix(), cx); editor.change_selections(Default::default(), window, cx, |s| { s.move_with(&mut |map, selection| { let mut cursor = selection.head().to_point(map); From 31fc9d5f4710e30a4908525f6f0b930fce71e6f6 Mon Sep 17 00:00:00 2001 From: justinschmitz97 <68192785+justinschmitz97@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:04:53 +0200 Subject: [PATCH 033/197] project_panel: Continue batch delete when individual entries fail (#59595) ## Summary On Windows, `trash::delete_with_info` can return an error even when the file was successfully moved to the Recycle Bin. This is caused by a race condition in the `trash` crate: after `IFileOperation::PerformOperations` completes, it re-binds the trashed item via `SHCreateItemFromParsingName` to read its metadata, but the shell's virtual namespace cache may not yet reflect the new item, producing an error. Previously, the delete loop propagated this error and aborted the entire batch. The outer `detach_and_log_err` swallowed the error, so the user received no feedback. This PR handles each entry independently: entries that fail to delete are skipped, the loop continues, and undo history is recorded for the entries that succeeded. The upstream root cause (the post-operation re-bind race in `zed-industries/trash-rs`) will be addressed separately. Before: https://github.com/user-attachments/assets/59a95ac5-098b-42dc-bffb-f3240c6b966d After: https://github.com/user-attachments/assets/0cd6c0b3-2ee3-4bf2-a243-3e8a83d05dd7 ## Test plan - [ ] Select multiple files in the project panel on Windows - [ ] Trash them (right-click > Move to Trash, or `Delete` key) - [ ] Confirm all selected files are deleted, even if one triggers the trash-crate race - [ ] Undo restores the files that were successfully trashed Release Notes: - Improved handling of failed trash or delete operations in the Project Panel in order to display a toast informing the user that some files could not be trashed or deleted --------- Co-authored-by: dino --- Cargo.lock | 1 + crates/fs/src/fs.rs | 20 ++++- crates/project_panel/Cargo.toml | 1 + crates/project_panel/src/project_panel.rs | 105 +++++++++++++++++++--- crates/project_panel/src/tests/undo.rs | 22 +++++ 5 files changed, 133 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 920c4e48122..bc57517ab75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14091,6 +14091,7 @@ dependencies = [ "gpui", "itertools 0.14.0", "language", + "log", "markdown_preview", "menu", "notifications", diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 746c45d9f82..4c4a3912263 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -2397,7 +2397,18 @@ impl FakeFs { self.state .lock() .remove_dir_errors - .insert(normalize_path(path.as_ref()), message); + .insert(Self::remove_dir_error_key(path.as_ref()), message); + } + + /// Entry resolution in `try_entry` ignores drive prefixes, so the error + /// injection map must too. + /// Otherwise, on Windows, a key like `C:\workspace\dir` would never match a + /// lookup for `\workspace\dir`. + fn remove_dir_error_key(path: &Path) -> PathBuf { + normalize_path(path) + .components() + .skip_while(|component| matches!(component, Component::Prefix(_))) + .collect() } pub fn paths(&self, include_dot_git: bool) -> Vec { @@ -2542,7 +2553,12 @@ impl FakeFs { self.simulate_random_delay().await; let path = normalize_path(path); - if let Some(message) = self.state.lock().remove_dir_errors.get(&path) { + if let Some(message) = self + .state + .lock() + .remove_dir_errors + .get(&Self::remove_dir_error_key(&path)) + { anyhow::bail!("{message}"); } let parent_path = path.parent().context("cannot remove the root")?; diff --git a/crates/project_panel/Cargo.toml b/crates/project_panel/Cargo.toml index eac58314f08..89e97ee9c78 100644 --- a/crates/project_panel/Cargo.toml +++ b/crates/project_panel/Cargo.toml @@ -51,6 +51,7 @@ telemetry.workspace = true notifications.workspace = true feature_flags.workspace = true fs.workspace = true +log.workspace = true [dev-dependencies] client = { workspace = true, features = ["test-support"] } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 059f0685078..bf3b33efc72 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -640,6 +640,16 @@ fn get_item_color(is_sticky: bool, cx: &App) -> ItemColors { } } +enum DeleteEntryOutcome { + /// Entry was successfully trashed, returning the `Change` that can be + /// recorded to the undo stack. + Trashed(Change), + /// Entry was successfully deleted, or there was nothing to delete. + Deleted, + /// Deleting or trashing the entry failed at the OS level. + Failed, +} + impl ProjectPanel { fn new( workspace: &mut Workspace, @@ -2062,6 +2072,32 @@ impl ProjectPanel { })) } + async fn resolve_delete_entry( + task: Option>>>, + worktree_id: WorktreeId, + trash: bool, + ) -> DeleteEntryOutcome { + let Some(task) = task else { + // If there's no task to be awaited on, it means that the entry, or + // worktree, were likely already deleted. + return DeleteEntryOutcome::Deleted; + }; + + match task.await { + Ok(Some(trashed_entry)) if trash => { + DeleteEntryOutcome::Trashed(Change::Trashed(worktree_id, trashed_entry)) + } + Err(err) => { + log::error!( + "Failed to {} an entry: {err:#}", + if trash { "trash" } else { "delete" } + ); + DeleteEntryOutcome::Failed + } + Ok(_) => DeleteEntryOutcome::Deleted, + } + } + fn discard_edit_state(&mut self, window: &mut Window, cx: &mut Context) { if let Some(edit_state) = self.state.edit_state.take() { self.state.temporarily_unfolded_pending_state = edit_state @@ -2621,29 +2657,37 @@ impl ProjectPanel { } let mut changes = Vec::new(); + let total_count = file_paths.len(); + let mut failed_count = 0; - for (entry_id, worktree_id, _) in file_paths { - let trashed_entry = panel - .update(cx, |panel, cx| { - panel - .project - .update(cx, |project, cx| project.delete_entry(entry_id, trash, cx)) - .context("no such entry") - })?? - .await?; + let tasks = panel.update(cx, |panel, cx| { + panel.project.update(cx, |project, cx| { + file_paths + .into_iter() + .map(|(entry_id, worktree_id, _)| { + (worktree_id, project.delete_entry(entry_id, trash, cx)) + }) + .collect::>() + }) + })?; - // Keep track of trashed change so that we can then record - // all of the changes at once, such that undoing and redoing - // restores or trashes all files in batch. - if trash && let Some(trashed_entry) = trashed_entry { - changes.push(Change::Trashed(worktree_id, trashed_entry)); + for (worktree_id, task) in tasks { + match Self::resolve_delete_entry(task, worktree_id, trash).await { + DeleteEntryOutcome::Deleted => {} + DeleteEntryOutcome::Trashed(change) => changes.push(change), + DeleteEntryOutcome::Failed => failed_count += 1, } } + panel.update_in(cx, |panel, window, cx| { if trash { panel.undo_manager.record(changes).log_err(); } + if failed_count > 0 { + panel.show_remove_failure_toast(trash, total_count, failed_count, cx); + } + if let Some(next_selection) = next_selection { panel.update_visible_entries( Some((next_selection.worktree_id, next_selection.entry_id)), @@ -2663,6 +2707,39 @@ impl ProjectPanel { }); } + /// Displays a toast notification, informing that the application was not + /// able to delete/trash some of the files the user intended to + /// delete/trash. + fn show_remove_failure_toast( + &self, + trash: bool, + total_count: usize, + failed_count: usize, + cx: &mut Context, + ) { + let message = match (trash, total_count) { + (true, 1) => format!("Failed to trash {failed_count} of {total_count} file."), + (true, _) => format!("Failed to trash {failed_count} of {total_count} files."), + (false, 1) => format!("Failed to delete {failed_count} of {total_count} file."), + (false, _) => format!("Failed to delete {failed_count} of {total_count} files."), + }; + + let toast = StatusToast::new(message, cx, |this, _| { + this.icon( + Icon::new(IconName::XCircle) + .size(IconSize::Small) + .color(Color::Error), + ) + .dismiss_button(true) + }); + + self.workspace + .update(cx, |workspace, cx| { + workspace.toggle_status_toast(toast, cx); + }) + .ok(); + } + fn find_next_selection_after_deletion( &self, sanitized_entries: BTreeSet, diff --git a/crates/project_panel/src/tests/undo.rs b/crates/project_panel/src/tests/undo.rs index 4315a6ecb4c..1e69c7443ae 100644 --- a/crates/project_panel/src/tests/undo.rs +++ b/crates/project_panel/src/tests/undo.rs @@ -382,3 +382,25 @@ async fn trash_undo_redo(cx: &mut gpui::TestAppContext) { cx.redo().await; cx.assert_fs_state_is(&[]); } + +#[gpui::test] +async fn trash_continues_when_one_entry_fails(cx: &mut gpui::TestAppContext) { + let mut cx = TestContext::new_with_tree( + cx, + json!({ + "0_dir": {}, + "a.txt": "", + "b.txt": "", + }), + ) + .await; + + cx.fs + .set_remove_dir_error(path("/workspace/0_dir"), "simulated failure".into()); + + cx.trash(&["0_dir", "a.txt", "b.txt"]).await; + cx.assert_fs_state_is(&["0_dir/"]); + + cx.undo().await; + cx.assert_fs_state_is(&["0_dir/", "a.txt", "b.txt"]); +} From ea87b0579464067eb45a1c1a1f2c1bdb80af7e1f Mon Sep 17 00:00:00 2001 From: Anant Goel Date: Thu, 2 Jul 2026 11:14:11 -0700 Subject: [PATCH 034/197] Fix worktree grouping for bare checkouts (#59968) Summary - Track whether a worktree root is itself a linked Git worktree. - Use that metadata when computing project group keys so bare checkout worktrees group under the repository identity path. - Propagate the metadata through remote worktree protocols and add local/remote regression coverage. Background Bare checkout layouts can place linked worktrees under the repository identity directory, e.g. `/monty/.bare` with worktrees like `/monty/feature-a`. We were treating those linked worktree paths as separate project identities, which caused the sidebar to move agent threads under the active worktree instead of the shared repository group. We also exclude adding this to collab intentionally, we can open a different PR for that if we need to. Closes #59910 Closes AI-431 Test Plan - `cargo fmt --package project --package worktree --package remote_server --package workspace --package collab --package proto` - `git --no-pager diff --check` - `cargo test -p project test_project_group_key -- --nocapture` - `cargo test -p remote_server test_remote_root_repo_common_dir -- --nocapture` - `cargo test -p worktree remote_worktree -- --nocapture` - `cargo test -p workspace test_remote_project_root_dir_changes_update_groups -- --nocapture` - `cargo check -p collab` 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 Release Notes: - Fixed agent thread/sidebar grouping for Git worktrees backed by bare checkouts. --------- Co-authored-by: Anthony Eid --- crates/collab/src/db.rs | 2 + crates/collab/src/rpc.rs | 6 + crates/project/src/project.rs | 1 + crates/project/src/worktree_store.rs | 6 +- .../tests/integration/project_tests.rs | 182 ++++++++++++++++++ crates/proto/proto/call.proto | 1 + crates/proto/proto/worktree.proto | 2 + crates/proto/src/proto.rs | 1 + crates/remote_server/src/headless_project.rs | 1 + .../remote_server/src/remote_editing_tests.rs | 40 +++- crates/workspace/src/multi_workspace_tests.rs | 1 + crates/worktree/src/worktree.rs | 82 ++++++-- .../tests/integration/worktree_tests.rs | 93 +++++++++ 13 files changed, 398 insertions(+), 20 deletions(-) diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 60e49fc39a6..d8dad9fec13 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -525,6 +525,8 @@ impl RejoinedProject { visible: worktree.visible, abs_path: worktree.abs_path.clone(), root_repo_common_dir: None, + // todo(collab): Get this field from database + root_repo_is_linked_worktree: false, }) .collect(), collaborators: self diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index c0ea34bf3aa..431350971ce 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -1599,6 +1599,8 @@ fn notify_rejoined_projects( abs_path: worktree.abs_path.clone(), root_name: worktree.root_name, root_repo_common_dir: worktree.root_repo_common_dir, + // todo(collab): Get this field from database + root_repo_is_linked_worktree: false, updated_entries: worktree.updated_entries, removed_entries: worktree.removed_entries, scan_id: worktree.scan_id, @@ -2007,6 +2009,8 @@ async fn join_project( visible: worktree.visible, abs_path: worktree.abs_path.clone(), root_repo_common_dir: None, + // todo(collab): Get this field from database + root_repo_is_linked_worktree: false, }) .collect::>(); @@ -2059,6 +2063,8 @@ async fn join_project( abs_path: worktree.abs_path.clone(), root_name: worktree.root_name, root_repo_common_dir: worktree.root_repo_common_dir, + // todo(collab): Get this field from database + root_repo_is_linked_worktree: false, updated_entries: worktree.entries, removed_entries: Default::default(), scan_id: worktree.scan_id, diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 12a3a263d2d..7043c243d83 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -2148,6 +2148,7 @@ impl Project { visible: true, abs_path: abs_path.to_string(), root_repo_common_dir: None, + root_repo_is_linked_worktree: false, }, client, PathStyle::Posix, diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs index fd63c972f1d..911f9f79f3b 100644 --- a/crates/project/src/worktree_store.rs +++ b/crates/project/src/worktree_store.rs @@ -827,6 +827,7 @@ impl WorktreeStore { visible, abs_path: response.canonicalized_path, root_repo_common_dir: response.root_repo_common_dir, + root_repo_is_linked_worktree: response.root_repo_is_linked_worktree, }, client, path_style, @@ -1155,6 +1156,7 @@ impl WorktreeStore { root_repo_common_dir: worktree .root_repo_common_dir() .map(|p| p.to_string_lossy().into_owned()), + root_repo_is_linked_worktree: worktree.root_repo_is_linked_worktree(), } }) .collect() @@ -1371,7 +1373,9 @@ impl WorktreeStore { .root_repo_common_dir() .map(|dir| crate::git_store::repo_identity_path(dir)) .filter(|repo_path| { - *repo_path == folder_path.as_path() || !folder_path.starts_with(*repo_path) + snapshot.root_repo_is_linked_worktree() + || *repo_path == folder_path.as_path() + || !folder_path.starts_with(*repo_path) }) .map(Path::to_path_buf) .unwrap_or_else(|| folder_path.clone()); diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index 16e948f3a73..da62617d286 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -12070,6 +12070,188 @@ async fn test_project_group_keys_remain_distinct_for_sibling_repo_subdirectories ); } +fn project_group_key_paths(project: &Entity, cx: &TestAppContext) -> Vec { + project.read_with(cx, |project, cx| { + ProjectGroupKey::from_project(project, cx) + .path_list() + .ordered_paths() + .cloned() + .collect() + }) +} + +fn project_worktree_paths( + project: &Entity, + cx: &TestAppContext, +) -> (Vec, Vec) { + project.read_with(cx, |project, cx| { + let paths = project.worktree_paths(cx); + ( + paths.folder_path_list().ordered_paths().cloned().collect(), + paths + .main_worktree_path_list() + .ordered_paths() + .cloned() + .collect(), + ) + }) +} + +#[gpui::test] +async fn test_project_group_keys_match_for_bare_repo_linked_worktrees( + executor: gpui::BackgroundExecutor, + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor); + fs.insert_tree( + path!("/root"), + json!({ + "monty": { + ".git": "gitdir: ./.bare\n", + ".bare": { + "HEAD": "ref: refs/heads/main\n", + "worktrees": { + "main": { + "commondir": "../..", + "gitdir": "/root/monty/main/.git", + }, + "feature-a": { + "commondir": "../..", + "gitdir": "/root/monty/feature-a/.git", + }, + "feature-b": { + "commondir": "../..", + "gitdir": "/root/monty/feature-b/.git", + }, + }, + }, + "main": { + ".git": "gitdir: /root/monty/.bare/worktrees/main\n", + "file.txt": "main", + }, + "feature-a": { + ".git": "gitdir: /root/monty/.bare/worktrees/feature-a\n", + "file.txt": "a", + }, + "feature-b": { + ".git": "gitdir: /root/monty/.bare/worktrees/feature-b\n", + "file.txt": "b", + }, + }, + }), + ) + .await; + + let project_root = Project::test(fs.clone(), [path!("/root/monty").as_ref()], cx).await; + let project_main = Project::test(fs.clone(), [path!("/root/monty/main").as_ref()], cx).await; + let project_a = Project::test(fs.clone(), [path!("/root/monty/feature-a").as_ref()], cx).await; + let project_b = Project::test(fs, [path!("/root/monty/feature-b").as_ref()], cx).await; + + project_root + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + project_main + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + project_a + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + project_b + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + cx.run_until_parked(); + + for project in [&project_root, &project_main, &project_a, &project_b] { + assert_eq!( + project_group_key_paths(project, cx), + vec![PathBuf::from(path!("/root/monty"))] + ); + } + + assert_eq!( + project_worktree_paths(&project_root, cx), + ( + vec![PathBuf::from(path!("/root/monty"))], + vec![PathBuf::from(path!("/root/monty"))], + ) + ); + assert_eq!( + project_worktree_paths(&project_main, cx), + ( + vec![PathBuf::from(path!("/root/monty/main"))], + vec![PathBuf::from(path!("/root/monty"))], + ) + ); + assert_eq!( + project_worktree_paths(&project_a, cx), + ( + vec![PathBuf::from(path!("/root/monty/feature-a"))], + vec![PathBuf::from(path!("/root/monty"))], + ) + ); + assert_eq!( + project_worktree_paths(&project_b, cx), + ( + vec![PathBuf::from(path!("/root/monty/feature-b"))], + vec![PathBuf::from(path!("/root/monty"))], + ) + ); +} + +#[gpui::test] +async fn test_project_group_key_groups_nested_linked_worktree_under_main_repo( + executor: gpui::BackgroundExecutor, + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor); + fs.insert_tree( + path!("/root"), + json!({ + "my-repo": { + ".git": {}, + "file.txt": "content", + }, + }), + ) + .await; + + let linked_worktree_path = PathBuf::from(path!("/root/my-repo/.zed/worktrees/feature")); + fs.add_linked_worktree_for_repo( + Path::new(path!("/root/my-repo/.git")), + false, + git::repository::Worktree { + path: linked_worktree_path.clone(), + ref_name: Some("refs/heads/feature".into()), + sha: "abc123".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + + let project = Project::test(fs, [linked_worktree_path.as_path()], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + cx.run_until_parked(); + + assert_eq!( + project_group_key_paths(&project, cx), + vec![PathBuf::from(path!("/root/my-repo"))] + ); + assert_eq!( + project_worktree_paths(&project, cx), + ( + vec![PathBuf::from(path!("/root/my-repo/.zed/worktrees/feature"))], + vec![PathBuf::from(path!("/root/my-repo"))], + ) + ); +} + #[gpui::test] async fn test_repository_subfolder_git_status( executor: gpui::BackgroundExecutor, diff --git a/crates/proto/proto/call.proto b/crates/proto/proto/call.proto index aa79014959a..180d1e5659f 100644 --- a/crates/proto/proto/call.proto +++ b/crates/proto/proto/call.proto @@ -231,6 +231,7 @@ message UpdateWorktree { bool is_last_update = 9; string abs_path = 10; optional string root_repo_common_dir = 11; + bool root_repo_is_linked_worktree = 12; } // deprecated diff --git a/crates/proto/proto/worktree.proto b/crates/proto/proto/worktree.proto index 08a1317f6ac..285b4c9056d 100644 --- a/crates/proto/proto/worktree.proto +++ b/crates/proto/proto/worktree.proto @@ -41,6 +41,7 @@ message AddWorktreeResponse { uint64 worktree_id = 1; string canonicalized_path = 2; optional string root_repo_common_dir = 3; + bool root_repo_is_linked_worktree = 4; } message RemoveWorktree { @@ -64,6 +65,7 @@ message WorktreeMetadata { bool visible = 3; string abs_path = 4; optional string root_repo_common_dir = 5; + bool root_repo_is_linked_worktree = 6; } message ProjectPath { diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index b998300f065..3fbd2352757 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -924,6 +924,7 @@ pub fn split_worktree_update(mut message: UpdateWorktree) -> impl Iterator>() + }); + assert_eq!( + main_worktree_paths, + vec![ + PathBuf::from("/code/main_repo"), + PathBuf::from("/code/main_repo"), + ] + ); // No git repo: root_repo_common_dir should be None. let (worktree_no_git, _) = project @@ -2195,10 +2219,14 @@ async fn test_remote_root_repo_common_dir(cx: &mut TestAppContext, server_cx: &m .unwrap(); cx.executor().run_until_parked(); - let common_dir = worktree_no_git.read_with(cx, |worktree, _| { - worktree.snapshot().root_repo_common_dir().cloned() + let (common_dir, is_linked_worktree) = worktree_no_git.read_with(cx, |worktree, _| { + ( + worktree.snapshot().root_repo_common_dir().cloned(), + worktree.snapshot().root_repo_is_linked_worktree(), + ) }); assert_eq!(common_dir, None); + assert!(!is_linked_worktree); } #[gpui::test] diff --git a/crates/workspace/src/multi_workspace_tests.rs b/crates/workspace/src/multi_workspace_tests.rs index 323fc10fb01..e0a6bb1e0eb 100644 --- a/crates/workspace/src/multi_workspace_tests.rs +++ b/crates/workspace/src/multi_workspace_tests.rs @@ -846,6 +846,7 @@ async fn test_remote_project_root_dir_changes_update_groups(cx: &mut TestAppCont updated_repositories: vec![], removed_repositories: vec![], root_repo_common_dir: None, + root_repo_is_linked_worktree: false, }); }); cx.run_until_parked(); diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 2711362dd18..c31385f4553 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -181,6 +181,7 @@ pub struct Snapshot { entries_by_path: SumTree, entries_by_id: SumTree, root_repo_common_dir: Option>, + root_repo_is_linked_worktree: bool, always_included_entries: Vec>, /// A number that increases every time the worktree begins scanning @@ -421,12 +422,18 @@ impl Worktree { None }; - let root_repo_common_dir = if visible { - discover_root_repo_common_dir(&abs_path, fs.as_ref()) + let (root_repo_common_dir, root_repo_is_linked_worktree) = if visible { + discover_root_repo_metadata(&abs_path, fs.as_ref()) .await - .map(SanitizedPath::from_arc) + .map(|(common_dir, is_linked_worktree)| { + ( + Some(SanitizedPath::from_arc(common_dir)), + is_linked_worktree, + ) + }) + .unwrap_or((None, false)) } else { - None + (None, false) }; Ok(cx.new(move |cx: &mut Context| { let mut snapshot = LocalSnapshot { @@ -447,6 +454,7 @@ impl Worktree { root_file_handle, }; snapshot.root_repo_common_dir = root_repo_common_dir; + snapshot.root_repo_is_linked_worktree = root_repo_is_linked_worktree; let worktree_id = snapshot.id(); let settings_location = Some(SettingsLocation { @@ -535,6 +543,7 @@ impl Worktree { snapshot.root_repo_common_dir = worktree .root_repo_common_dir .map(|p| SanitizedPath::new_arc(Path::new(&p))); + snapshot.root_repo_is_linked_worktree = worktree.root_repo_is_linked_worktree; let background_snapshot = Arc::new(Mutex::new(( snapshot.clone(), @@ -597,6 +606,8 @@ impl Worktree { } let old_root_repo_common_dir = this.snapshot.root_repo_common_dir.clone(); + let old_root_repo_is_linked_worktree = + this.snapshot.root_repo_is_linked_worktree; let mut changed_entries: Vec<(Arc, ProjectEntryId, PathChange)> = Vec::new(); { @@ -639,6 +650,8 @@ impl Worktree { let is_first_update = !this.received_initial_update; this.received_initial_update = true; if this.snapshot.root_repo_common_dir != old_root_repo_common_dir + || this.snapshot.root_repo_is_linked_worktree + != old_root_repo_is_linked_worktree || (is_first_update && this.snapshot.root_repo_common_dir.is_none()) { cx.emit(Event::UpdatedRootRepoCommonDir { @@ -734,6 +747,7 @@ impl Worktree { root_repo_common_dir: self .root_repo_common_dir() .map(|p| p.to_string_lossy().into_owned()), + root_repo_is_linked_worktree: self.root_repo_is_linked_worktree(), } } @@ -1274,13 +1288,28 @@ impl LocalWorktree { ) { let repo_changes = self.changed_repos(&self.snapshot, &mut new_snapshot); - new_snapshot.root_repo_common_dir = new_snapshot + if let Some((common_dir, is_linked_worktree)) = new_snapshot .local_repo_for_work_directory_path(RelPath::empty()) - .map(|repo| SanitizedPath::from_arc(repo.common_dir_abs_path.clone())); + .map(|repo| { + ( + SanitizedPath::from_arc(repo.common_dir_abs_path.clone()), + repo.repository_dir_abs_path != repo.common_dir_abs_path, + ) + }) + { + new_snapshot.root_repo_common_dir = Some(common_dir); + new_snapshot.root_repo_is_linked_worktree = is_linked_worktree; + } else { + new_snapshot.root_repo_common_dir = None; + new_snapshot.root_repo_is_linked_worktree = false; + } - let old_root_repo_common_dir = (self.snapshot.root_repo_common_dir - != new_snapshot.root_repo_common_dir) - .then(|| self.snapshot.root_repo_common_dir.clone()); + let root_repo_metadata_changed = self.snapshot.root_repo_common_dir + != new_snapshot.root_repo_common_dir + || self.snapshot.root_repo_is_linked_worktree + != new_snapshot.root_repo_is_linked_worktree; + let old_root_repo_common_dir = + root_repo_metadata_changed.then(|| self.snapshot.root_repo_common_dir.clone()); self.snapshot = new_snapshot; if let Some(share) = self.update_observer.as_mut() { @@ -2361,6 +2390,7 @@ impl Snapshot { entries_by_path: Default::default(), entries_by_id: Default::default(), root_repo_common_dir: None, + root_repo_is_linked_worktree: false, scan_id: 1, completed_scan_id: 0, } @@ -2392,6 +2422,10 @@ impl Snapshot { .map(SanitizedPath::cast_arc_ref) } + pub fn root_repo_is_linked_worktree(&self) -> bool { + self.root_repo_is_linked_worktree + } + fn build_initial_update(&self, project_id: u64, worktree_id: u64) -> proto::UpdateWorktree { let mut updated_entries = self .entries_by_path @@ -2408,6 +2442,7 @@ impl Snapshot { root_repo_common_dir: self .root_repo_common_dir() .map(|p| p.to_string_lossy().into_owned()), + root_repo_is_linked_worktree: self.root_repo_is_linked_worktree, updated_entries, removed_entries: Vec::new(), scan_id: self.scan_id as u64, @@ -2553,11 +2588,21 @@ impl Snapshot { self.entries_by_path.edit(entries_by_path_edits, ()); self.entries_by_id.edit(entries_by_id_edits, ()); - if let Some(dir) = update + // A `None` from a completed scan is a real repo removal, whereas a `None` + // mid-scan may just mean the sender hasn't registered the root repo yet. + match update .root_repo_common_dir .map(|p| SanitizedPath::new_arc(Path::new(&p))) { - self.root_repo_common_dir = Some(dir); + Some(dir) => { + self.root_repo_common_dir = Some(dir); + self.root_repo_is_linked_worktree = update.root_repo_is_linked_worktree; + } + None if update.is_last_update => { + self.root_repo_common_dir = None; + self.root_repo_is_linked_worktree = false; + } + None => {} } self.scan_id = update.scan_id as usize; @@ -2790,6 +2835,7 @@ impl LocalSnapshot { root_repo_common_dir: self .root_repo_common_dir() .map(|p| p.to_string_lossy().into_owned()), + root_repo_is_linked_worktree: self.root_repo_is_linked_worktree, updated_entries, removed_entries, scan_id: self.scan_id as u64, @@ -6621,13 +6667,23 @@ fn resolve_commondir_path(repository_dir_abs_path: &Path, commondir_path: &str) } pub async fn discover_root_repo_common_dir(root_abs_path: &Path, fs: &dyn Fs) -> Option> { + discover_root_repo_metadata(root_abs_path, fs) + .await + .map(|(common_dir, _)| common_dir) +} + +async fn discover_root_repo_metadata( + root_abs_path: &Path, + fs: &dyn Fs, +) -> Option<(Arc, bool)> { let root_dot_git = root_abs_path.join(DOT_GIT); if !fs.metadata(&root_dot_git).await.is_ok_and(|m| m.is_some()) { return None; } let dot_git_path: Arc = root_dot_git.into(); - let (_, common_dir) = discover_git_paths(&dot_git_path, fs).await; - Some(common_dir) + let (repository_dir, common_dir) = discover_git_paths(&dot_git_path, fs).await; + let is_linked_worktree = repository_dir != common_dir; + Some((common_dir, is_linked_worktree)) } async fn discover_git_paths(dot_git_abs_path: &Arc, fs: &dyn Fs) -> (Arc, Arc) { diff --git a/crates/worktree/tests/integration/worktree_tests.rs b/crates/worktree/tests/integration/worktree_tests.rs index 6a4370539af..1fd7ed5b557 100644 --- a/crates/worktree/tests/integration/worktree_tests.rs +++ b/crates/worktree/tests/integration/worktree_tests.rs @@ -4932,6 +4932,7 @@ async fn test_remote_worktree_without_git_emits_root_repo_event_after_first_upda visible: true, abs_path: "/home/user/project".to_string(), root_repo_common_dir: None, + root_repo_is_linked_worktree: false, }, client, PathStyle::Posix, @@ -4989,6 +4990,7 @@ async fn test_remote_worktree_without_git_emits_root_repo_event_after_first_upda updated_repositories: vec![], removed_repositories: vec![], root_repo_common_dir: None, + root_repo_is_linked_worktree: false, }); }); @@ -5027,6 +5029,7 @@ async fn test_remote_worktree_with_git_emits_root_repo_event_when_repo_info_arri visible: true, abs_path: "/home/user/project".to_string(), root_repo_common_dir: None, + root_repo_is_linked_worktree: false, }, client, PathStyle::Posix, @@ -5081,6 +5084,7 @@ async fn test_remote_worktree_with_git_emits_root_repo_event_when_repo_info_arri updated_repositories: vec![], removed_repositories: vec![], root_repo_common_dir: Some("/home/user/project/.git".to_string()), + root_repo_is_linked_worktree: false, }); }); @@ -5101,6 +5105,94 @@ async fn test_remote_worktree_with_git_emits_root_repo_event_when_repo_info_arri ); } +#[gpui::test] +async fn test_remote_worktree_root_repo_metadata_cleared_only_by_completed_scan( + cx: &mut TestAppContext, +) { + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + }); + + let client = AnyProtoClient::new(NoopProtoClient::new()); + + // Metadata eagerly seeds the root repo info, as `AddWorktreeResponse` / + // `WorktreeMetadata` do before the host's scan completes. + let worktree = cx.update(|cx| { + Worktree::remote( + 1, + clock::ReplicaId::new(1), + proto::WorktreeMetadata { + id: 1, + root_name: "feature-a".to_string(), + visible: true, + abs_path: "/home/user/monty/feature-a".to_string(), + root_repo_common_dir: Some("/home/user/monty/.bare".to_string()), + root_repo_is_linked_worktree: true, + }, + client, + PathStyle::Posix, + cx, + ) + }); + + let root_repo_metadata = |cx: &mut TestAppContext| { + worktree.read_with(cx, |worktree, _| { + let snapshot = worktree.snapshot(); + ( + snapshot.root_repo_common_dir().cloned(), + snapshot.root_repo_is_linked_worktree(), + ) + }) + }; + + let update = |scan_id: u64, is_last_update: bool| proto::UpdateWorktree { + project_id: 1, + worktree_id: 1, + abs_path: "/home/user/monty/feature-a".to_string(), + root_name: "feature-a".to_string(), + updated_entries: vec![], + removed_entries: vec![], + scan_id, + is_last_update, + updated_repositories: vec![], + removed_repositories: vec![], + root_repo_common_dir: None, + root_repo_is_linked_worktree: false, + }; + + // A mid-scan update without repo info must not clobber the seeded + // metadata: the sender's scanner may not have registered the repo yet. + worktree.update(cx, |worktree, _cx| { + worktree + .as_remote() + .unwrap() + .update_from_remote(update(2, false)); + }); + cx.run_until_parked(); + + assert_eq!( + root_repo_metadata(cx), + (Some(Arc::from(Path::new("/home/user/monty/.bare"))), true,), + "mid-scan update without repo info should not clear seeded metadata" + ); + + // A completed scan without repo info is authoritative: the repo is gone. + worktree.update(cx, |worktree, _cx| { + worktree + .as_remote() + .unwrap() + .update_from_remote(update(3, true)); + }); + cx.run_until_parked(); + + assert_eq!( + root_repo_metadata(cx), + (None, false), + "completed scan without repo info should clear root repo metadata" + ); +} + // Regression test: a remote worktree used to emit `UpdatedEntries` with an // empty changeset (`Arc::default()`), discarding the changed paths. Consumers // that key off those paths - notably the agent's `.agents/skills` refresh - @@ -5152,6 +5244,7 @@ async fn test_remote_worktree_update_entries_carry_changed_paths(cx: &mut TestAp visible: true, abs_path: path!("/root").to_string(), root_repo_common_dir: None, + root_repo_is_linked_worktree: false, }, AnyProtoClient::new(NoopProtoClient::new()), PathStyle::local(), From 4aa8ad9742b1ee948d64429a5814d9b9a861350a Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 2 Jul 2026 20:38:37 +0200 Subject: [PATCH 035/197] agent: Make terminal threads searchable (#60292) Makes cmd-f work for terminal threads. Since the agent panel is not part of the normal pane system we have to manually hook up the search bar. Closes #57309 Release Notes: - agent: Added support for searching (cmd-f) inside of terminal threads --- assets/keymaps/default-linux.json | 7 +++ assets/keymaps/default-macos.json | 7 +++ assets/keymaps/default-windows.json | 7 +++ crates/agent_ui/src/agent_panel.rs | 71 +++++++++++++++++++++++++++-- 4 files changed, 87 insertions(+), 5 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 90a440a6447..8799e032bfa 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -298,6 +298,13 @@ "alt-r": "search::ToggleRegex", }, }, + { + "context": "AgentTerminalThread", + "use_key_equivalents": true, + "bindings": { + "ctrl-f": "agent::ToggleSearch", + }, + }, { "context": "AcpThreadSearchBar", "use_key_equivalents": true, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 8b4e76dd81f..de0f8bd2b46 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -336,6 +336,13 @@ "alt-cmd-x": "search::ToggleRegex", }, }, + { + "context": "AgentTerminalThread", + "use_key_equivalents": true, + "bindings": { + "cmd-f": "agent::ToggleSearch", + }, + }, { "context": "AcpThreadSearchBar", "use_key_equivalents": true, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 08a9274c317..acc2a82e18e 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -299,6 +299,13 @@ "alt-r": "search::ToggleRegex", }, }, + { + "context": "AgentTerminalThread", + "use_key_equivalents": true, + "bindings": { + "ctrl-f": "agent::ToggleSearch", + }, + }, { "context": "AcpThreadSearchBar", "use_key_equivalents": true, diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index f22037c239a..40c15c71794 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -85,6 +85,7 @@ use project::{Project, ProjectPath, Worktree}; use settings::TerminalDockPosition; use settings::{NotifyWhenAgentWaiting, Settings, update_settings_file}; +use search::{BufferSearchBar, buffer_search::Deploy as DeployBufferSearch}; use terminal::{Event as TerminalEvent, terminal_settings::TerminalSettings}; use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; use text::OffsetRangeExt; @@ -96,9 +97,9 @@ use ui::{ use util::ResultExt as _; use workspace::{ CollaboratorId, DraggedSelection, DraggedTab, MultiWorkspace, PathList, SerializedPathList, - ToggleWorkspaceSidebar, ToggleZoom, Workspace, WorkspaceId, + ToggleWorkspaceSidebar, ToggleZoom, ToolbarItemView, Workspace, WorkspaceId, dock::{DockPosition, Panel, PanelEvent}, - item::ItemEvent, + item::{ItemEvent, ItemHandle}, }; const AGENT_PANEL_KEY: &str = "agent_panel"; @@ -991,6 +992,7 @@ struct AgentTerminal { working_directory: Option, created_at: DateTime, has_notification: bool, + search_bar: Option>, notification_windows: Vec>, notification_subscriptions: Vec, _subscriptions: Vec, @@ -2174,6 +2176,7 @@ impl AgentPanel { working_directory, created_at: created_at.unwrap_or_else(Utc::now), has_notification: false, + search_bar: None, notification_windows: Vec::new(), notification_subscriptions: Vec::new(), _subscriptions: vec![view_subscription, terminal_subscription], @@ -3949,6 +3952,37 @@ impl AgentPanel { } } + fn toggle_terminal_thread_search( + &mut self, + _: &crate::ToggleSearch, + window: &mut Window, + cx: &mut Context, + ) { + let Some(terminal) = self + .active_terminal_id() + .and_then(|terminal_id| self.terminals.get_mut(&terminal_id)) + else { + cx.propagate(); + return; + }; + + let terminal_view = terminal.view.clone(); + let search_bar = terminal + .search_bar + .get_or_insert_with(|| cx.new(|cx| BufferSearchBar::new(None, window, cx))) + .clone(); + let deployed = search_bar.update(cx, |search_bar, cx| { + let terminal_item: &dyn ItemHandle = &terminal_view; + search_bar.set_active_pane_item(Some(terminal_item), window, cx); + search_bar.deploy(&DeployBufferSearch::find(), None, window, cx) + }); + if deployed { + cx.stop_propagation(); + } else { + cx.propagate(); + } + } + pub fn conversation_view_for_id( &self, thread_id: &ThreadId, @@ -6415,6 +6449,7 @@ impl Render for AgentPanel { .on_action(cx.listener(Self::decrease_font_size)) .on_action(cx.listener(Self::reset_font_size)) .on_action(cx.listener(Self::toggle_zoom)) + .on_action(cx.listener(Self::toggle_terminal_thread_search)) .on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| { if let Some(conversation_view) = this.active_conversation_view() { conversation_view.update(cx, |conversation_view, cx| { @@ -6439,9 +6474,35 @@ 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()) - .child(self.render_drag_target(cx)), + VisibleSurface::Terminal(terminal_view) => { + let search_bar = self + .active_terminal_id() + .and_then(|terminal_id| self.terminals.get(&terminal_id)) + .and_then(|terminal| terminal.search_bar.clone()); + let terminal_content = v_flex() + .key_context("AgentTerminalThread") + .size_full() + .when_some(search_bar, |this, search_bar| { + this.when(!search_bar.read(cx).is_dismissed(), |this| { + this.child( + v_flex() + .group("toolbar") + .relative() + .py(DynamicSpacing::Base06.rems(cx)) + .px(DynamicSpacing::Base08.rems(cx)) + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().toolbar_background) + .child(search_bar), + ) + }) + }) + .child(terminal_view.clone()); + + parent + .child(terminal_content) + .child(self.render_drag_target(cx)) + } }) .children(self.render_trial_end_upsell(window, cx)); From 2882636c06923e58d83865ecc370bd0d8199d738 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:13:08 -0700 Subject: [PATCH 036/197] Fix hanging updates after system sleep (#60301) When Zed was downloading an update and the machine went to sleep, the download would hang indefinitely on wake because the in-flight TCP connection had silently died and nothing ever timed it out or retried. This adds an inactivity `read_timeout` to the HTTP client so a stalled response body errors out instead of hanging forever (slow-but-progressing downloads are unaffected, since the timeout resets on each chunk). It also promotes `App::on_system_wake` to a multi-subscriber `Subscription` API and uses it in the auto-updater to cancel an interrupted check/download on wake and start a fresh attempt. ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fix hanging Zed update downloads after system sleep --- crates/auto_update/src/auto_update.rs | 28 ++++++++++++ crates/gpui/src/app.rs | 47 +++++++++++++-------- crates/reqwest_client/src/reqwest_client.rs | 2 + 3 files changed, 60 insertions(+), 17 deletions(-) diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index 9786aa84d16..eb6afcfd67c 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -170,6 +170,7 @@ pub struct AutoUpdater { pending_poll: Option>>, quit_subscription: Option, update_check_type: UpdateCheckType, + _wake_subscription: gpui::Subscription, } #[derive(Deserialize, Serialize, Clone, Debug)] @@ -409,6 +410,16 @@ impl AutoUpdater { }) .detach(); + // A download or check that was in flight when the machine went to sleep + // is almost certainly riding a TCP connection that silently died during + // suspend, so it would otherwise appear to stall indefinitely. + let wake_subscription = cx.on_system_wake({ + let this = cx.entity().downgrade(); + move |cx| { + this.update(cx, |this, cx| this.restart_after_wake(cx)).ok(); + } + }); + Self { status: AutoUpdateStatus::Idle, current_version, @@ -416,9 +427,26 @@ impl AutoUpdater { pending_poll: None, quit_subscription, update_check_type: UpdateCheckType::Automatic, + _wake_subscription: wake_subscription, } } + fn restart_after_wake(&mut self, cx: &mut Context) { + // Only network phases can be safely restarted. `Installing` is a local + // operation (mounting a dmg, rsync, etc.) that must not be interrupted. + if !matches!( + self.status, + AutoUpdateStatus::Checking | AutoUpdateStatus::Downloading { .. } + ) { + return; + } + + let check_type = self.update_check_type; + self.pending_poll.take(); + self.status = AutoUpdateStatus::Idle; + self.poll(check_type, cx); + } + pub fn start_polling(&self, cx: &mut Context) -> Task> { let poll_interval = ReleaseChannel::try_global(cx).map_or(POLL_INTERVAL, |channel| match channel { diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index e6ca25ecae0..ab302a8f06d 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -234,23 +234,6 @@ impl Application { self } - /// Invokes a handler when the system wakes from sleep. - pub fn on_system_wake(&self, mut callback: F) -> &Self - where - F: 'static + FnMut(&mut App), - { - let this = Rc::downgrade(&self.0); - self.0 - .borrow_mut() - .platform - .on_system_wake(Box::new(move || { - if let Some(app) = this.upgrade() { - callback(&mut app.borrow_mut()); - } - })); - self - } - /// Returns a handle to the [`BackgroundExecutor`] associated with this app, which can be used to spawn futures in the background. pub fn background_executor(&self) -> BackgroundExecutor { self.0.borrow().background_executor.clone() @@ -656,6 +639,7 @@ pub struct App { pub(crate) keystroke_interceptors: SubscriberSet<(), KeystrokeObserver>, pub(crate) keyboard_layout_observers: SubscriberSet<(), Handler>, pub(crate) thermal_state_observers: SubscriberSet<(), Handler>, + pub(crate) system_wake_observers: SubscriberSet<(), Handler>, pub(crate) release_listeners: SubscriberSet, pub(crate) global_observers: SubscriberSet, pub(crate) quit_observers: SubscriberSet<(), QuitHandler>, @@ -779,6 +763,7 @@ impl App { keystroke_interceptors: SubscriberSet::new(), keyboard_layout_observers: SubscriberSet::new(), thermal_state_observers: SubscriberSet::new(), + system_wake_observers: SubscriberSet::new(), global_observers: SubscriberSet::new(), quit_observers: SubscriberSet::new(), restart_observers: SubscriberSet::new(), @@ -835,6 +820,18 @@ impl App { } })); + platform.on_system_wake(Box::new({ + let app = Rc::downgrade(&app); + move || { + if let Some(app) = app.upgrade() { + let cx = &mut app.borrow_mut(); + cx.system_wake_observers + .clone() + .retain(&(), move |callback| (callback)(cx)); + } + } + })); + platform.on_quit(Box::new({ let cx = Rc::downgrade(&app); move || { @@ -1256,6 +1253,22 @@ impl App { subscription } + /// Invokes a handler when the system wakes from sleep. + pub fn on_system_wake(&self, mut callback: F) -> Subscription + where + F: 'static + FnMut(&mut App), + { + let (subscription, activate) = self.system_wake_observers.insert( + (), + Box::new(move |cx| { + callback(cx); + true + }), + ); + activate(); + subscription + } + /// Returns the appearance of the application's windows. pub fn window_appearance(&self) -> WindowAppearance { self.platform.window_appearance() diff --git a/crates/reqwest_client/src/reqwest_client.rs b/crates/reqwest_client/src/reqwest_client.rs index 60908d8a8d4..e654c6bf620 100644 --- a/crates/reqwest_client/src/reqwest_client.rs +++ b/crates/reqwest_client/src/reqwest_client.rs @@ -30,6 +30,8 @@ impl ReqwestClient { reqwest::Client::builder() .use_rustls_tls() .connect_timeout(Duration::from_secs(10)) + // Bail out of a request whose body stops producing bytes entirely + .read_timeout(Duration::from_secs(30)) // Detect and drop connections that have silently gone bad on a // flaky path (NAT timeouts, resets) instead of reusing them. A // stale reused HTTP/2 connection is a common source of From bb48a42983f2a4bb9ac9d31c63abe02497088f67 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Thu, 2 Jul 2026 15:24:34 -0400 Subject: [PATCH 037/197] keymap_editor: Fix deleting and editing bindings that use deprecated action names (#60300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective In the keymap editor, bindings whose `keymap.json` entries use a deprecated action alias (e.g. `"editor::CopyRelativePath"` for `workspace::CopyRelativePath`) could not be deleted or edited. Aliases resolve to the canonical action on load, so the row displays normally, but file updates searched for the canonical name and never matched the alias entry. Deleting failed ("Failed to find keybinding to remove"), and editing silently fell back to appending a new binding — leaving the user with duplicate identical-looking rows they couldn't remove. ## Solution - Thread gpui's deprecated-alias map (`App::deprecated_actions_to_preferred_actions`) into `KeymapFile::update_keybinding`, and resolve aliases in file entries when matching bindings. - As a side effect, editing an alias-based binding now rewrites the entry with the canonical action name instead of duplicating it. ## Testing - Unit tests in `settings` for removing and replacing alias-based entries. - End-to-end keymap editor tests (fake fs → keymap load → `KeymapEditor` → delete) covering both the alias case and plain duplicate bindings. ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - Fixed the keymap editor failing to delete or edit key bindings whose keymap file entries use deprecated action names --- crates/keymap_editor/src/keymap_editor.rs | 213 +++++++++++++++++++++- crates/settings/src/keymap_file.rs | 209 ++++++++++++++++++++- 2 files changed, 413 insertions(+), 9 deletions(-) diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index 0bb66ab988c..aecaea7fcba 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -1436,8 +1436,15 @@ impl KeymapEditor { self.table_interaction_state.read(cx).scroll_offset(), )); let keyboard_mapper = cx.keyboard_mapper().clone(); + let deprecated_aliases = cx.deprecated_actions_to_preferred_actions().clone(); cx.spawn(async move |_, _| { - remove_keybinding(to_remove, &fs, keyboard_mapper.as_ref()).await + remove_keybinding( + to_remove, + &fs, + keyboard_mapper.as_ref(), + &deprecated_aliases, + ) + .await }) .detach_and_notify_err(self.workspace.clone(), window, cx); } @@ -2839,6 +2846,7 @@ impl KeybindingEditorModal { let create = self.creating; let keyboard_mapper = cx.keyboard_mapper().clone(); + let deprecated_aliases = cx.deprecated_actions_to_preferred_actions().clone(); let action_name = self .get_selected_action_name(cx) @@ -2869,6 +2877,7 @@ impl KeybindingEditorModal { new_action_args.as_deref(), &fs, keyboard_mapper.as_ref(), + &deprecated_aliases, ) .await { @@ -3607,6 +3616,7 @@ async fn save_keybinding_update( new_args: Option<&str>, fs: &Arc, keyboard_mapper: &dyn PlatformKeyboardMapper, + deprecated_aliases: &HashMap<&'static str, &'static str>, ) -> anyhow::Result<()> { let keymap_contents = settings::KeymapFile::load_keymap_file(fs) .await @@ -3656,6 +3666,7 @@ async fn save_keybinding_update( keymap_contents, tab_size, keyboard_mapper, + deprecated_aliases, ) .map_err(|err| anyhow::anyhow!("Could not save updated keybinding: {}", err))?; fs.write( @@ -3678,6 +3689,7 @@ async fn remove_keybinding( existing: ProcessedBinding, fs: &Arc, keyboard_mapper: &dyn PlatformKeyboardMapper, + deprecated_aliases: &HashMap<&'static str, &'static str>, ) -> anyhow::Result<()> { let Some(keystrokes) = existing.keystrokes() else { anyhow::bail!("Cannot remove a keybinding that does not exist"); @@ -3707,6 +3719,7 @@ async fn remove_keybinding( keymap_contents, tab_size, keyboard_mapper, + deprecated_aliases, ) .context("Failed to update keybinding")?; fs.write( @@ -4015,6 +4028,204 @@ mod persistence { #[cfg(test)] mod tests { use super::*; + use fs::FakeFs; + use gpui::{TestAppContext, VisualTestContext}; + use project::Project; + use serde_json::json; + use settings::KeymapFileLoadResult; + use workspace::{AppState, MultiWorkspace}; + + async fn reload_keymap_from_file(fs: &Arc, cx: &mut TestAppContext) { + let content = fs.load(paths::keymap_file().as_path()).await.unwrap(); + cx.update(|cx| { + let mut key_bindings = match KeymapFile::load(&content, cx) { + KeymapFileLoadResult::Success { key_bindings } => key_bindings, + KeymapFileLoadResult::SomeFailedToLoad { error_message, .. } => { + panic!("keymap failed to load: {error_message:?}") + } + KeymapFileLoadResult::JsonParseFailure { error } => { + panic!("keymap json parse failure: {error}") + } + }; + cx.clear_key_bindings(); + for key_binding in &mut key_bindings { + key_binding.set_meta(KeybindSource::User.meta()); + } + cx.bind_keys(key_bindings); + KeymapEventChannel::trigger_keymap_changed(cx); + }); + } + + async fn setup_keymap_editor( + cx: &mut TestAppContext, + keymap_content: &str, + ) -> (Arc, Entity, VisualTestContext) { + cx.update(|cx| { + let _state = AppState::test(cx); + editor::init(cx); + cx.set_global(KeymapEventChannel::new()); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + paths::config_dir(), + json!({ "keymap.json": keymap_content }), + ) + .await; + + reload_keymap_from_file(&fs, cx).await; + + let project = Project::test(fs.clone(), [], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + let keymap_editor = cx + .update(|window, cx| cx.new(|cx| KeymapEditor::new(workspace.downgrade(), window, cx))); + cx.run_until_parked(); + (fs, keymap_editor, cx) + } + + fn visible_rows_for_action(editor: &KeymapEditor, action_name: &str) -> Vec { + editor + .matches + .iter() + .enumerate() + .filter(|(_, string_match)| { + let binding = &editor.keybindings[string_match.candidate_id]; + binding.action().name == action_name && binding.keystrokes().is_some() + }) + .map(|(index, _)| index) + .collect() + } + + #[gpui::test] + async fn test_delete_one_of_two_identical_user_bindings(cx: &mut TestAppContext) { + let keymap_content = r#"[ + { + "bindings": { + "alt-cmd-shift-c": "zed::OpenKeymap" + } + }, + { + "bindings": { + "alt-cmd-shift-c": "zed::OpenKeymap" + } + } +]"#; + let (fs, keymap_editor, mut cx) = setup_keymap_editor(cx, keymap_content).await; + let cx = &mut cx; + + let rows = keymap_editor.read_with(cx, |editor, _| { + visible_rows_for_action(editor, "zed::OpenKeymap") + }); + assert_eq!( + rows.len(), + 2, + "expected the two duplicate bindings to show as two rows" + ); + + keymap_editor.update_in(cx, |editor, window, cx| { + editor.selected_index = Some(rows[1]); + editor.delete_binding(&DeleteBinding, window, cx); + }); + cx.run_until_parked(); + + let content = fs.load(paths::keymap_file().as_path()).await.unwrap(); + assert_eq!( + content.matches("alt-cmd-shift-c").count(), + 1, + "expected exactly one binding remaining in the keymap file, got:\n{content}" + ); + + // Simulate the keymap file watcher reacting to the change. + reload_keymap_from_file(&fs, cx).await; + cx.run_until_parked(); + + let rows = keymap_editor.read_with(cx, |editor, _| { + visible_rows_for_action(editor, "zed::OpenKeymap") + }); + assert_eq!(rows.len(), 1, "expected one row remaining after deletion"); + } + + // Regression test: one of the two entries in the keymap file uses a + // deprecated alias of the action (`editor::CopyRelativePath` instead of + // `workspace::CopyRelativePath`). Both rows display identically in the + // keymap editor (aliases resolve to the canonical action on load), but + // deletion targets the canonical action name, so `KeymapFile::update_keybinding` + // used to never find the alias entry, making it impossible to delete. + #[gpui::test] + async fn test_delete_binding_with_deprecated_action_alias(cx: &mut TestAppContext) { + let keymap_content = r#"[ + { + "bindings": { + "alt-cmd-shift-c": "editor::CopyRelativePath" + } + }, + { + "bindings": { + "alt-cmd-shift-c": "workspace::CopyRelativePath" + } + } +]"#; + let (fs, keymap_editor, mut cx) = setup_keymap_editor(cx, keymap_content).await; + let cx = &mut cx; + + let rows = keymap_editor.read_with(cx, |editor, _| { + visible_rows_for_action(editor, "workspace::CopyRelativePath") + }); + assert_eq!( + rows.len(), + 2, + "both the alias and the canonical entry should show as (identical) rows" + ); + + // Delete the first row. Both rows report the canonical action name, so + // `find_binding` matches the canonical file entry and removes it. + keymap_editor.update_in(cx, |editor, window, cx| { + editor.selected_index = Some(rows[0]); + editor.delete_binding(&DeleteBinding, window, cx); + }); + cx.run_until_parked(); + + let content = fs.load(paths::keymap_file().as_path()).await.unwrap(); + assert_eq!( + content.matches("alt-cmd-shift-c").count(), + 1, + "first deletion should remove one of the two entries, got:\n{content}" + ); + + // Simulate the keymap file watcher reacting to the change. + reload_keymap_from_file(&fs, cx).await; + cx.run_until_parked(); + + let rows = keymap_editor.read_with(cx, |editor, _| { + visible_rows_for_action(editor, "workspace::CopyRelativePath") + }); + assert_eq!( + rows.len(), + 1, + "one row should remain after the first deletion" + ); + + // Delete the remaining row (the alias entry). `find_binding` must + // resolve the deprecated alias in the file to the canonical action + // name to find and remove it. + keymap_editor.update_in(cx, |editor, window, cx| { + editor.selected_index = Some(rows[0]); + editor.delete_binding(&DeleteBinding, window, cx); + }); + cx.run_until_parked(); + + let content = fs.load(paths::keymap_file().as_path()).await.unwrap(); + assert_eq!( + content.matches("alt-cmd-shift-c").count(), + 0, + "second deletion should remove the remaining (alias) entry, got:\n{content}" + ); + } #[test] fn normalized_ctx_cmp() { diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index 9f734939d73..72d5606af43 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -893,6 +893,7 @@ impl KeymapFile { mut keymap_contents: String, tab_size: usize, keyboard_mapper: &dyn gpui::PlatformKeyboardMapper, + deprecated_aliases: &HashMap<&'static str, &'static str>, ) -> Result { // When replacing or removing a non-user binding, we may need to write an unbind entry // to suppress the original default binding. @@ -937,9 +938,13 @@ impl KeymapFile { let target_action_value = target .action_value() .context("Failed to generate target action JSON value")?; - let Some(binding_location) = - find_binding(&keymap, target, &target_action_value, keyboard_mapper) - else { + let Some(binding_location) = find_binding( + &keymap, + target, + &target_action_value, + keyboard_mapper, + deprecated_aliases, + ) else { anyhow::bail!("Failed to find keybinding to remove"); }; let is_only_binding = binding_location.is_only_entry_in_section(&keymap); @@ -973,9 +978,13 @@ impl KeymapFile { .action_value() .context("Failed to generate source action JSON value")?; - if let Some(binding_location) = - find_binding(&keymap, &target, &target_action_value, keyboard_mapper) - { + if let Some(binding_location) = find_binding( + &keymap, + &target, + &target_action_value, + keyboard_mapper, + deprecated_aliases, + ) { if target.context == source.context { // if we are only changing the keybinding (common case) // not the context, etc. Then just update the binding in place @@ -1072,7 +1081,7 @@ impl KeymapFile { let use_key_equivalents = from.and_then(|from| { let action_value = from.action_value().context("Failed to serialize action value. `use_key_equivalents` on new keybinding may be incorrect.").log_err()?; let binding_location = - find_binding(&keymap, &from, &action_value, keyboard_mapper)?; + find_binding(&keymap, &from, &action_value, keyboard_mapper, deprecated_aliases)?; Some(keymap.0[binding_location.index].use_key_equivalents) }).unwrap_or(false); if use_key_equivalents { @@ -1122,6 +1131,7 @@ impl KeymapFile { target: &KeybindUpdateTarget<'a>, target_action_value: &Value, keyboard_mapper: &dyn gpui::PlatformKeyboardMapper, + deprecated_aliases: &HashMap<&'static str, &'static str>, ) -> Option> { let target_context_parsed = KeyBindingContextPredicate::parse(target.context.unwrap_or("")).ok(); @@ -1139,6 +1149,7 @@ impl KeymapFile { target, target_action_value, keyboard_mapper, + deprecated_aliases, |action| &action.0, ) { return Some(binding_location); @@ -1151,6 +1162,7 @@ impl KeymapFile { target, target_action_value, keyboard_mapper, + deprecated_aliases, |action| &action.0, ) { return Some(binding_location); @@ -1166,6 +1178,7 @@ impl KeymapFile { target: &KeybindUpdateTarget<'a>, target_action_value: &Value, keyboard_mapper: &dyn gpui::PlatformKeyboardMapper, + deprecated_aliases: &HashMap<&'static str, &'static str>, action_value: impl Fn(&T) -> &Value, ) -> Option> { let entries = entries?; @@ -1192,7 +1205,11 @@ impl KeymapFile { { continue; } - if action_value(action) != target_action_value { + if !action_value_matches_target( + action_value(action), + target_action_value, + deprecated_aliases, + ) { continue; } return Some(BindingLocation { @@ -1204,6 +1221,40 @@ impl KeymapFile { None } + /// Compares a keymap file entry's action value against the target action + /// value. The target is built from a loaded binding's canonical action + /// name, while the file entry may still use a deprecated alias. + fn action_value_matches_target( + action_value: &Value, + target_action_value: &Value, + deprecated_aliases: &HashMap<&'static str, &'static str>, + ) -> bool { + if action_value == target_action_value { + return true; + } + let (name, arguments) = match action_value { + Value::String(name) => (name.as_str(), None), + Value::Array(items) => match items.as_slice() { + [Value::String(name), arguments] => (name.as_str(), Some(arguments)), + _ => return false, + }, + _ => return false, + }; + let Some(&preferred_name) = deprecated_aliases.get(name) else { + return false; + }; + match target_action_value { + Value::String(target_name) => target_name == preferred_name && arguments.is_none(), + Value::Array(items) => match items.as_slice() { + [Value::String(target_name), target_arguments] => { + target_name == preferred_name && arguments == Some(target_arguments) + } + _ => false, + }, + _ => false, + } + } + #[derive(Copy, Clone)] enum BindingKind { Binding, @@ -1525,6 +1576,7 @@ impl Action for ActionSequence { #[cfg(test)] mod tests { + use collections::HashMap; use gpui::{Action, App, DummyKeyboardMapper, KeybindingKeystroke, Keystroke, Unbind}; use serde_json::Value; use unindent::Unindent; @@ -1742,11 +1794,23 @@ mod tests { operation: KeybindUpdateOperation, expected: impl ToString, ) { + check_keymap_update_with_deprecated_aliases(input, operation, expected, &[]); + } + + #[track_caller] + fn check_keymap_update_with_deprecated_aliases( + input: impl ToString, + operation: KeybindUpdateOperation, + expected: impl ToString, + deprecated_aliases: &[(&'static str, &'static str)], + ) { + let deprecated_aliases = HashMap::from_iter(deprecated_aliases.iter().copied()); let result = KeymapFile::update_keybinding( operation, input.to_string(), 4, &gpui::DummyKeyboardMapper, + &deprecated_aliases, ) .expect("Update succeeded"); pretty_assertions::assert_eq!(expected.to_string(), result); @@ -2567,6 +2631,135 @@ mod tests { ); } + #[test] + fn test_keymap_remove_duplicate_binding() { + zlog::init_test(); + + // Repro: user created two identical bindings via the keymap editor UI, + // resulting in two sections with the same keystrokes and action. + // Deleting one of them should remove exactly one section. + check_keymap_update( + r#" + [ + { + "bindings": { + "alt-cmd-shift-c": "workspace::CopyRelativePath" + } + }, + { + "bindings": { + "alt-cmd-shift-c": "workspace::CopyRelativePath" + } + } + ] + "# + .unindent(), + KeybindUpdateOperation::Remove { + target: KeybindUpdateTarget { + context: None, + keystrokes: &parse_keystrokes("alt-cmd-shift-c"), + action_name: "workspace::CopyRelativePath", + action_arguments: None, + }, + target_keybind_source: KeybindSource::User, + }, + r#" + [ + { + "bindings": { + "alt-cmd-shift-c": "workspace::CopyRelativePath" + } + } + ] + "# + .unindent(), + ); + } + + #[test] + fn test_keymap_update_deprecated_alias_binding() { + zlog::init_test(); + + // The keymap file entry uses a deprecated alias of the action, while the + // update target uses the canonical name (as reported by the loaded binding). + let deprecated_aliases = &[("editor::CopyRelativePath", "workspace::CopyRelativePath")]; + + check_keymap_update_with_deprecated_aliases( + r#" + [ + { + "bindings": { + "alt-cmd-shift-c": "editor::CopyRelativePath", + "a": "foo::Bar" + } + } + ] + "# + .unindent(), + KeybindUpdateOperation::Remove { + target: KeybindUpdateTarget { + context: None, + keystrokes: &parse_keystrokes("alt-cmd-shift-c"), + action_name: "workspace::CopyRelativePath", + action_arguments: None, + }, + target_keybind_source: KeybindSource::User, + }, + r#" + [ + { + "bindings": { + "a": "foo::Bar" + } + } + ] + "# + .unindent(), + deprecated_aliases, + ); + + // Editing an alias entry should update it in place (rewriting it with the + // canonical name) instead of falling back to appending a new binding. + check_keymap_update_with_deprecated_aliases( + r#" + [ + { + "bindings": { + "alt-cmd-shift-c": "editor::CopyRelativePath" + } + } + ] + "# + .unindent(), + KeybindUpdateOperation::Replace { + target: KeybindUpdateTarget { + context: None, + keystrokes: &parse_keystrokes("alt-cmd-shift-c"), + action_name: "workspace::CopyRelativePath", + action_arguments: None, + }, + source: KeybindUpdateTarget { + context: None, + keystrokes: &parse_keystrokes("ctrl-alt-c"), + action_name: "workspace::CopyRelativePath", + action_arguments: None, + }, + target_keybind_source: KeybindSource::User, + }, + r#" + [ + { + "bindings": { + "ctrl-alt-c": "workspace::CopyRelativePath" + } + } + ] + "# + .unindent(), + deprecated_aliases, + ); + } + #[test] fn test_keymap_remove() { zlog::init_test(); From b5c2d8a13f395bbdbaf9cb74bda16bbcb00414d1 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Thu, 2 Jul 2026 19:54:11 -0400 Subject: [PATCH 038/197] Revert "Fix hanging updates after system sleep (#60301)" (#60321) This reverts commit 2882636c06923e58d83865ecc370bd0d8199d738. This was causing Zed to crash immediately on startup with the following error: ``` thread 'main' (74835290) panicked at /Users/maxdeviant/.cargo/git/checkouts/reqwest-dc13ba947e7b959e/c156624/src/async_impl/body.rs:365:33: there is no reactor running, must be called from the context of a Tokio 1.x runtime ``` Closes FR-118. Release Notes: - Reverted https://github.com/zed-industries/zed/pull/60301 --- crates/auto_update/src/auto_update.rs | 28 ------------ crates/gpui/src/app.rs | 47 ++++++++------------- crates/reqwest_client/src/reqwest_client.rs | 2 - 3 files changed, 17 insertions(+), 60 deletions(-) diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index eb6afcfd67c..9786aa84d16 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -170,7 +170,6 @@ pub struct AutoUpdater { pending_poll: Option>>, quit_subscription: Option, update_check_type: UpdateCheckType, - _wake_subscription: gpui::Subscription, } #[derive(Deserialize, Serialize, Clone, Debug)] @@ -410,16 +409,6 @@ impl AutoUpdater { }) .detach(); - // A download or check that was in flight when the machine went to sleep - // is almost certainly riding a TCP connection that silently died during - // suspend, so it would otherwise appear to stall indefinitely. - let wake_subscription = cx.on_system_wake({ - let this = cx.entity().downgrade(); - move |cx| { - this.update(cx, |this, cx| this.restart_after_wake(cx)).ok(); - } - }); - Self { status: AutoUpdateStatus::Idle, current_version, @@ -427,26 +416,9 @@ impl AutoUpdater { pending_poll: None, quit_subscription, update_check_type: UpdateCheckType::Automatic, - _wake_subscription: wake_subscription, } } - fn restart_after_wake(&mut self, cx: &mut Context) { - // Only network phases can be safely restarted. `Installing` is a local - // operation (mounting a dmg, rsync, etc.) that must not be interrupted. - if !matches!( - self.status, - AutoUpdateStatus::Checking | AutoUpdateStatus::Downloading { .. } - ) { - return; - } - - let check_type = self.update_check_type; - self.pending_poll.take(); - self.status = AutoUpdateStatus::Idle; - self.poll(check_type, cx); - } - pub fn start_polling(&self, cx: &mut Context) -> Task> { let poll_interval = ReleaseChannel::try_global(cx).map_or(POLL_INTERVAL, |channel| match channel { diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index ab302a8f06d..e6ca25ecae0 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -234,6 +234,23 @@ impl Application { self } + /// Invokes a handler when the system wakes from sleep. + pub fn on_system_wake(&self, mut callback: F) -> &Self + where + F: 'static + FnMut(&mut App), + { + let this = Rc::downgrade(&self.0); + self.0 + .borrow_mut() + .platform + .on_system_wake(Box::new(move || { + if let Some(app) = this.upgrade() { + callback(&mut app.borrow_mut()); + } + })); + self + } + /// Returns a handle to the [`BackgroundExecutor`] associated with this app, which can be used to spawn futures in the background. pub fn background_executor(&self) -> BackgroundExecutor { self.0.borrow().background_executor.clone() @@ -639,7 +656,6 @@ pub struct App { pub(crate) keystroke_interceptors: SubscriberSet<(), KeystrokeObserver>, pub(crate) keyboard_layout_observers: SubscriberSet<(), Handler>, pub(crate) thermal_state_observers: SubscriberSet<(), Handler>, - pub(crate) system_wake_observers: SubscriberSet<(), Handler>, pub(crate) release_listeners: SubscriberSet, pub(crate) global_observers: SubscriberSet, pub(crate) quit_observers: SubscriberSet<(), QuitHandler>, @@ -763,7 +779,6 @@ impl App { keystroke_interceptors: SubscriberSet::new(), keyboard_layout_observers: SubscriberSet::new(), thermal_state_observers: SubscriberSet::new(), - system_wake_observers: SubscriberSet::new(), global_observers: SubscriberSet::new(), quit_observers: SubscriberSet::new(), restart_observers: SubscriberSet::new(), @@ -820,18 +835,6 @@ impl App { } })); - platform.on_system_wake(Box::new({ - let app = Rc::downgrade(&app); - move || { - if let Some(app) = app.upgrade() { - let cx = &mut app.borrow_mut(); - cx.system_wake_observers - .clone() - .retain(&(), move |callback| (callback)(cx)); - } - } - })); - platform.on_quit(Box::new({ let cx = Rc::downgrade(&app); move || { @@ -1253,22 +1256,6 @@ impl App { subscription } - /// Invokes a handler when the system wakes from sleep. - pub fn on_system_wake(&self, mut callback: F) -> Subscription - where - F: 'static + FnMut(&mut App), - { - let (subscription, activate) = self.system_wake_observers.insert( - (), - Box::new(move |cx| { - callback(cx); - true - }), - ); - activate(); - subscription - } - /// Returns the appearance of the application's windows. pub fn window_appearance(&self) -> WindowAppearance { self.platform.window_appearance() diff --git a/crates/reqwest_client/src/reqwest_client.rs b/crates/reqwest_client/src/reqwest_client.rs index e654c6bf620..60908d8a8d4 100644 --- a/crates/reqwest_client/src/reqwest_client.rs +++ b/crates/reqwest_client/src/reqwest_client.rs @@ -30,8 +30,6 @@ impl ReqwestClient { reqwest::Client::builder() .use_rustls_tls() .connect_timeout(Duration::from_secs(10)) - // Bail out of a request whose body stops producing bytes entirely - .read_timeout(Duration::from_secs(30)) // Detect and drop connections that have silently gone bad on a // flaky path (NAT timeouts, resets) instead of reusing them. A // stale reused HTTP/2 connection is a common source of From 552fc9f3c3c775276c2ce3f0fb93f1f4c2c18ba6 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Thu, 2 Jul 2026 21:00:54 -0400 Subject: [PATCH 039/197] reqwest_client: Fix streamed request bodies being truncated (#60314) StreamReader::poll_next dropped the reader it take()s whenever the read returned Poll::Pending, so the next poll reported end-of-stream. And poll_read_buf measured bytes via ReadBuf::filled(), which never advances when the reader writes through initialize_unfilled(), so every read counted as zero bytes. Together these made any AsyncBody::from_reader request body (e.g. one backed by async_fs::File, which returns Pending on its first read) upload as an empty or truncated body. Keep the reader across Pending polls and use the byte count that futures::AsyncRead::poll_read returns. - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A Co-authored-by: Marshall --- crates/reqwest_client/src/reqwest_client.rs | 112 +++++++++++++++++--- 1 file changed, 98 insertions(+), 14 deletions(-) diff --git a/crates/reqwest_client/src/reqwest_client.rs b/crates/reqwest_client/src/reqwest_client.rs index 60908d8a8d4..066e95fc655 100644 --- a/crates/reqwest_client/src/reqwest_client.rs +++ b/crates/reqwest_client/src/reqwest_client.rs @@ -156,7 +156,11 @@ impl futures::Stream for StreamReader { } match poll_read_buf(&mut reader, cx, &mut this.buf) { - Poll::Pending => Poll::Pending, + Poll::Pending => { + self.reader = Some(reader); + + Poll::Pending + } Poll::Ready(Err(err)) => { self.reader = None; @@ -177,7 +181,7 @@ impl futures::Stream for StreamReader { /// Implementation from /// Specialized for this use case -pub fn poll_read_buf( +fn poll_read_buf( io: &mut Pin>, cx: &mut std::task::Context<'_>, buf: &mut BytesMut, @@ -190,22 +194,22 @@ pub fn poll_read_buf( let dst = buf.chunk_mut(); // Safety: `chunk_mut()` returns a `&mut UninitSlice`, and `UninitSlice` is a - // transparent wrapper around `[MaybeUninit]`. + // transparent wrapper around `[std::mem::MaybeUninit]`. let dst = unsafe { &mut *(dst as *mut _ as *mut [std::mem::MaybeUninit]) }; - let mut buf = tokio::io::ReadBuf::uninit(dst); - let ptr = buf.filled().as_ptr(); - let unfilled_portion = buf.initialize_unfilled(); + let mut read_buf = tokio::io::ReadBuf::uninit(dst); + let unfilled_portion = read_buf.initialize_unfilled(); // SAFETY: Pin projection let io_pin = unsafe { Pin::new_unchecked(io) }; - std::task::ready!(io_pin.poll_read(cx, unfilled_portion)?); - - // Ensure the pointer does not change from under us - assert_eq!(ptr, buf.filled().as_ptr()); - buf.filled().len() + // `futures::AsyncRead` reports the byte count as the poll's return + // value; `read_buf.filled()` stays empty because the reader writes + // through the initialized slice without advancing the `ReadBuf`. + std::task::ready!(io_pin.poll_read(cx, unfilled_portion)?) }; - // Safety: This is guaranteed to be the number of initialized (and read) - // bytes due to the invariants provided by `ReadBuf::filled`. + // Safety: `initialize_unfilled()` zero-initialized the entire spare + // capacity, so the first `n` bytes are initialized no matter how many the + // reader actually wrote, and `advance_mut` panics rather than exceeding + // the capacity if `n` overstates the slice length. unsafe { buf.advance_mut(n); } @@ -286,10 +290,90 @@ impl http_client::HttpClient for ReqwestClient { #[cfg(test)] mod tests { - use http_client::{HttpClient, Url}; + use std::io::{Read as _, Write as _}; + use std::net::TcpListener; + + use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest, Url}; use crate::ReqwestClient; + /// Regression test: `StreamReader::poll_next` used to drop the reader it + /// `take()`s whenever the reader returned `Poll::Pending`, so the next + /// poll reported end-of-stream and streamed request bodies were silently + /// truncated. Readers backed by real I/O (e.g. `async_fs::File`) return + /// `Pending` on their very first read, so their uploads sent zero bytes. + #[test] + fn test_streamed_body_survives_pending_reader() { + let payload: Vec = (0..30_000usize).map(|byte| (byte % 251) as u8).collect(); + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let expected_payload = payload.clone(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buffer = [0u8; 8192]; + loop { + let read = stream.read(&mut buffer).unwrap(); + assert_ne!(read, 0, "client closed the connection mid-request"); + request.extend_from_slice(&buffer[..read]); + if let Some(position) = request.windows(4).position(|w| w == b"\r\n\r\n") { + let body_start = position + 4; + while request.len() - body_start < expected_payload.len() { + let read = stream.read(&mut buffer).unwrap(); + assert_ne!(read, 0, "client closed the connection mid-body"); + request.extend_from_slice(&buffer[..read]); + } + assert_eq!(&request[body_start..], &expected_payload); + break; + } + } + stream + .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n") + .unwrap(); + }); + + // A reader that returns `Pending` before every chunk, like a reader + // backed by real I/O would. + struct PendingFirstReader { + data: std::io::Cursor>, + ready: bool, + } + + impl futures::AsyncRead for PendingFirstReader { + fn poll_read( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut [u8], + ) -> std::task::Poll> { + if self.ready { + self.ready = false; + std::task::Poll::Ready(self.data.read(buf)) + } else { + self.ready = true; + cx.waker().wake_by_ref(); + std::task::Poll::Pending + } + } + } + + let reader = PendingFirstReader { + data: std::io::Cursor::new(payload.clone()), + ready: false, + }; + + let client = ReqwestClient::new(); + let request = HttpRequest::builder() + .method(Method::PUT) + .uri(format!("http://{address}/upload")) + .header("Content-Length", payload.len().to_string()) + .body(AsyncBody::from_reader(reader)) + .unwrap(); + let response = futures::executor::block_on(client.send(request)).unwrap(); + assert!(response.status().is_success()); + server.join().unwrap(); + } + #[test] fn test_proxy_uri() { let client = ReqwestClient::new(); From 0346a17d097eabeb0f34f21f0a75fdd957bbf298 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Raz=20Guzm=C3=A1n=20Macedo?= Date: Thu, 2 Jul 2026 23:59:13 -0600 Subject: [PATCH 040/197] Add zed: get merch command palette action (#60330) Adds a `zed: get merch` command palette action that opens https://merch.zed.dev/ in the system browser. Just thought it'd be fun! Release Notes: - Added a `zed: get merch` action that opens the Zed merch store. --- crates/zed/src/zed.rs | 6 ++++-- crates/zed_actions/src/lib.rs | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 7e07e7f0f3f..22ba7cee3d4 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -101,12 +101,13 @@ use workspace::{ }; use workspace::{Pane, notifications::DetachAndPromptErr}; use zed_actions::{ - About, OpenAccountSettings, OpenBrowser, OpenDocs, OpenServerSettings, OpenSettingsFile, - OpenStatusPage, OpenZedUrl, Quit, + About, GetMerch, OpenAccountSettings, OpenBrowser, OpenDocs, OpenServerSettings, + OpenSettingsFile, OpenStatusPage, OpenZedUrl, Quit, }; const DOCS_URL: &str = "https://zed.dev/docs/"; const STATUS_URL: &str = "https://status.zed.dev"; +const MERCH_URL: &str = "https://merch.zed.dev/"; pub struct CrashHandler(pub Arc); @@ -887,6 +888,7 @@ fn register_actions( workspace .register_action(|_, _: &OpenDocs, _, cx| cx.open_url(DOCS_URL)) .register_action(|_, _: &OpenStatusPage, _, cx| cx.open_url(STATUS_URL)) + .register_action(|_, _: &GetMerch, _, cx| cx.open_url(MERCH_URL)) .register_action( |workspace: &mut Workspace, _: &input_latency_ui::DumpInputLatencyHistogram, diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 8b2d0d45df7..a11eca78e0b 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -69,6 +69,8 @@ actions!( OpenLicenses, /// Opens the Zed status page. OpenStatusPage, + /// Opens the Zed merch store. + GetMerch, /// Opens the telemetry log. OpenTelemetryLog, /// Opens the performance profiler. From f961889ad4d23ca0cf157a3f963bdb1159f27cce Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 3 Jul 2026 11:22:48 +0300 Subject: [PATCH 041/197] Fix excluded language servers starting nonetheless (#60000) Despite the default Zed settings containing an exclusion for `typescript-language-server` https://github.com/zed-industries/zed/blob/632dcae287b799975b1518b2c4914509e90a5c90/assets/settings/default.json#L2372-L2377 it actually starts every time I open the `*.ts` file which is wrong. Seems that the settings merging malfunctions hence the fix, but I have some doubts as I do not recall seeing this bad thing before? Before: before After: after Release Notes: - Fixed excluded language servers starting nonetheless --- crates/language/src/language_settings.rs | 16 +- crates/settings_content/src/language.rs | 253 ++++++++++++++++++++++- 2 files changed, 258 insertions(+), 11 deletions(-) diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index de67375cccc..1cf70338f46 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -19,7 +19,8 @@ pub use settings::{ AutoIndentMode, CompletionSettingsContent, EditPredictionDataCollectionChoice, EditPredictionPromptFormatContent, EditPredictionProvider, EditPredictionsMode, FormatOnSave, Formatter, FormatterList, InlayHintKind, LanguageSettingsContent, LineEndingSetting, - LspInsertMode, RewrapBehavior, ShowWhitespaceSetting, SoftWrap, WordsCompletionMode, + LspInsertMode, REST_OF_LANGUAGE_SERVERS, RewrapBehavior, ShowWhitespaceSetting, SoftWrap, + WordsCompletionMode, }; use settings::{RegisterSetting, Settings, SettingsLocation, SettingsStore, merge_from::MergeFrom}; use shellexpand; @@ -276,9 +277,6 @@ pub struct PrettierSettings { } impl LanguageSettings { - /// A token representing the rest of the available language servers. - const REST_OF_LANGUAGE_SERVERS: &'static str = "..."; - pub fn for_buffer<'a>(buffer: &'a Buffer, cx: &'a App) -> Cow<'a, LanguageSettings> { Self::resolve(Some(buffer), None, cx) } @@ -382,7 +380,7 @@ impl LanguageSettings { enabled_language_servers .into_iter() .flat_map(|language_server| { - if language_server.0.as_ref() == Self::REST_OF_LANGUAGE_SERVERS { + if language_server.0.as_ref() == REST_OF_LANGUAGE_SERVERS { rest.clone() } else { vec![language_server] @@ -1090,7 +1088,7 @@ mod tests { // A value of just `["..."]` is the same as taking all of the available language servers. assert_eq!( LanguageSettings::resolve_language_servers( - &[LanguageSettings::REST_OF_LANGUAGE_SERVERS.into()], + &[REST_OF_LANGUAGE_SERVERS.into()], &available_language_servers, ), available_language_servers @@ -1101,7 +1099,7 @@ mod tests { LanguageSettings::resolve_language_servers( &[ "biome".into(), - LanguageSettings::REST_OF_LANGUAGE_SERVERS.into(), + REST_OF_LANGUAGE_SERVERS.into(), "deno".into() ], &available_language_servers @@ -1122,7 +1120,7 @@ mod tests { "deno".into(), "!typescript-language-server".into(), "!biome".into(), - LanguageSettings::REST_OF_LANGUAGE_SERVERS.into() + REST_OF_LANGUAGE_SERVERS.into() ], &available_language_servers ), @@ -1134,7 +1132,7 @@ mod tests { LanguageSettings::resolve_language_servers( &[ "my-cool-language-server".into(), - LanguageSettings::REST_OF_LANGUAGE_SERVERS.into() + REST_OF_LANGUAGE_SERVERS.into() ], &available_language_servers ), diff --git a/crates/settings_content/src/language.rs b/crates/settings_content/src/language.rs index 2e5ef23875e..f31ef08e1d2 100644 --- a/crates/settings_content/src/language.rs +++ b/crates/settings_content/src/language.rs @@ -54,10 +54,32 @@ impl merge_from::MergeFrom for AllLanguageSettingsContent { // A user's global settings override the default global settings and // all default language-specific settings. - // self.defaults.merge_from(&other.defaults); + let globally_disabled_servers = other.defaults.language_servers.as_ref().map(|servers| { + servers + .iter() + .filter(|entry| entry.starts_with('!')) + .cloned() + .collect::>() + }); for language_settings in self.languages.0.values_mut() { + let language_server_overrides = language_settings.language_servers.clone(); language_settings.merge_from(&other.defaults); + if let Some(mut language_server_overrides) = language_server_overrides { + if let Some(disabled) = &globally_disabled_servers { + let insert_before = language_server_overrides + .iter() + .position(|entry| entry == REST_OF_LANGUAGE_SERVERS) + .unwrap_or(language_server_overrides.len()); + for disabled_server in disabled { + if !language_server_overrides.contains(disabled_server) { + language_server_overrides + .insert(insert_before, disabled_server.clone()); + } + } + } + language_settings.language_servers = Some(language_server_overrides); + } } // A user's language-specific settings override default language-specific settings. @@ -390,6 +412,8 @@ pub enum SoftWrap { Bounded, } +pub const REST_OF_LANGUAGE_SERVERS: &str = "..."; + /// The settings for a particular language. #[with_fallible_options] #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)] @@ -1157,7 +1181,7 @@ pub enum IndentGuideBackgroundColoring { #[cfg(test)] mod test { - use crate::{ParseStatus, fallible_options}; + use crate::{ParseStatus, fallible_options, merge_from::MergeFrom}; use super::*; @@ -1228,6 +1252,231 @@ mod test { assert!(matches!(result, ParseStatus::Failed { .. })); } + #[test] + fn test_language_servers_merge_preserves_per_language_config() { + let mut base = AllLanguageSettingsContent { + defaults: LanguageSettingsContent { + language_servers: Some(vec![REST_OF_LANGUAGE_SERVERS.into()]), + ..LanguageSettingsContent::default() + }, + languages: LanguageToSettingsMap( + [( + "TypeScript".into(), + LanguageSettingsContent { + language_servers: Some(vec![ + "!typescript-language-server".into(), + "vtsls".into(), + REST_OF_LANGUAGE_SERVERS.into(), + ]), + ..LanguageSettingsContent::default() + }, + )] + .into_iter() + .collect(), + ), + ..AllLanguageSettingsContent::default() + }; + + let user = AllLanguageSettingsContent { + defaults: LanguageSettingsContent { + language_servers: Some(vec![ + "!tailwindcss-language-server".into(), + "!eslint".into(), + REST_OF_LANGUAGE_SERVERS.into(), + ]), + ..LanguageSettingsContent::default() + }, + ..AllLanguageSettingsContent::default() + }; + + base.merge_from(&user); + + let ts_servers = base.languages.0["TypeScript"] + .language_servers + .as_ref() + .unwrap(); + assert_eq!( + ts_servers, + &vec![ + "!typescript-language-server".to_string(), + "vtsls".to_string(), + "!eslint".to_string(), + "!tailwindcss-language-server".to_string(), + REST_OF_LANGUAGE_SERVERS.to_string(), + ] + ); + + let default_servers = base.defaults.language_servers.as_ref().unwrap(); + assert_eq!( + default_servers, + &vec![ + "!tailwindcss-language-server".to_string(), + "!eslint".to_string(), + REST_OF_LANGUAGE_SERVERS.to_string(), + ] + ); + } + + #[test] + fn test_language_servers_merge_no_per_language_config_uses_global() { + let mut base = AllLanguageSettingsContent { + defaults: LanguageSettingsContent { + language_servers: Some(vec![REST_OF_LANGUAGE_SERVERS.into()]), + ..LanguageSettingsContent::default() + }, + languages: LanguageToSettingsMap( + [( + "Rust".into(), + LanguageSettingsContent { + tab_size: Some(std::num::NonZeroU32::new(4).unwrap()), + ..LanguageSettingsContent::default() + }, + )] + .into_iter() + .collect(), + ), + ..AllLanguageSettingsContent::default() + }; + + let user = AllLanguageSettingsContent { + defaults: LanguageSettingsContent { + language_servers: Some(vec!["!eslint".into(), REST_OF_LANGUAGE_SERVERS.into()]), + ..LanguageSettingsContent::default() + }, + ..AllLanguageSettingsContent::default() + }; + + base.merge_from(&user); + + let rust_servers = base.languages.0["Rust"].language_servers.as_ref().unwrap(); + assert_eq!( + rust_servers, + &vec!["!eslint".to_string(), REST_OF_LANGUAGE_SERVERS.to_string(),] + ); + } + + #[test] + fn test_language_servers_merge_user_per_language_overrides() { + let mut base = AllLanguageSettingsContent { + defaults: LanguageSettingsContent { + language_servers: Some(vec![REST_OF_LANGUAGE_SERVERS.into()]), + ..LanguageSettingsContent::default() + }, + languages: LanguageToSettingsMap( + [( + "TypeScript".into(), + LanguageSettingsContent { + language_servers: Some(vec![ + "!typescript-language-server".into(), + "vtsls".into(), + REST_OF_LANGUAGE_SERVERS.into(), + ]), + ..LanguageSettingsContent::default() + }, + )] + .into_iter() + .collect(), + ), + ..AllLanguageSettingsContent::default() + }; + + let user = AllLanguageSettingsContent { + defaults: LanguageSettingsContent { + language_servers: Some(vec!["!eslint".into(), REST_OF_LANGUAGE_SERVERS.into()]), + ..LanguageSettingsContent::default() + }, + languages: LanguageToSettingsMap( + [( + "TypeScript".into(), + LanguageSettingsContent { + language_servers: Some(vec![ + "deno".into(), + "!vtsls".into(), + REST_OF_LANGUAGE_SERVERS.into(), + ]), + ..LanguageSettingsContent::default() + }, + )] + .into_iter() + .collect(), + ), + ..AllLanguageSettingsContent::default() + }; + + base.merge_from(&user); + + let ts_servers = base.languages.0["TypeScript"] + .language_servers + .as_ref() + .unwrap(); + assert_eq!( + ts_servers, + &vec![ + "deno".to_string(), + "!vtsls".to_string(), + REST_OF_LANGUAGE_SERVERS.to_string(), + ] + ); + } + + #[test] + fn test_user_per_language_config_overrides_default_disables() { + let mut base = AllLanguageSettingsContent { + defaults: LanguageSettingsContent { + language_servers: Some(vec![REST_OF_LANGUAGE_SERVERS.into()]), + ..LanguageSettingsContent::default() + }, + languages: LanguageToSettingsMap( + [( + "TypeScript".into(), + LanguageSettingsContent { + language_servers: Some(vec![ + "!typescript-language-server".into(), + "vtsls".into(), + REST_OF_LANGUAGE_SERVERS.into(), + ]), + ..LanguageSettingsContent::default() + }, + )] + .into_iter() + .collect(), + ), + ..AllLanguageSettingsContent::default() + }; + + let user = AllLanguageSettingsContent { + languages: LanguageToSettingsMap( + [( + "TypeScript".into(), + LanguageSettingsContent { + language_servers: Some(vec![ + "typescript-language-server".into(), + REST_OF_LANGUAGE_SERVERS.into(), + ]), + ..LanguageSettingsContent::default() + }, + )] + .into_iter() + .collect(), + ), + ..AllLanguageSettingsContent::default() + }; + + base.merge_from(&user); + + let ts_servers = base.languages.0["TypeScript"] + .language_servers + .as_ref() + .unwrap(); + assert_eq!( + ts_servers, + &vec![ + "typescript-language-server".to_string(), + REST_OF_LANGUAGE_SERVERS.to_string(), + ] + ); + } + #[test] fn test_prettier_options() { let raw_prettier = r#"{"allowed": false, "tabWidth": 4, "semi": false}"#; From 4a3b763518c75c3d991985bd4eee822a56efc827 Mon Sep 17 00:00:00 2001 From: Chiel Robben Date: Fri, 3 Jul 2026 10:23:06 +0200 Subject: [PATCH 042/197] Add progress bar to "Downloading Zed Update..." button (#60294) # Objective On a few occasions I have been wondering if the Zed update download had stalled, when in fact it's just taking a while. This PR adds a progress bar to the "Downloading Zed Update..." button to give users feedback that the download is indeed progressing. ## Solution Add a custom progress bar inside the button. I tried reusing the existing ProgressBar component but that has a fixed height of 8px which caused the button to become taller. ## Testing - Tested manually using the command palette `collab: simulate update available` and stepping through the different UI states. - Added unit test ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase Image 02-07-2026 at 17 23 Release Notes: - Added a download progress indicator to the update button. --------- Co-authored-by: Smit Barmase --- crates/auto_update/src/auto_update.rs | 202 ++++++++++++++++-- crates/title_bar/src/update_version.rs | 5 +- .../ui/src/components/collab/update_button.rs | 60 ++++-- .../components/progress/circular_progress.rs | 17 +- 4 files changed, 250 insertions(+), 34 deletions(-) diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index 9786aa84d16..126e75ae838 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -13,7 +13,10 @@ use semver::Version; use serde::{Deserialize, Serialize}; use settings::{RegisterSetting, Settings, SettingsStore}; use smol::fs::File; -use smol::{fs, io::AsyncReadExt}; +use smol::{ + fs, + io::{AsyncReadExt, AsyncWriteExt}, +}; use std::mem; use std::{ env::{ @@ -126,20 +129,33 @@ pub struct AssetQuery<'a> { pub enum AutoUpdateStatus { Idle, Checking, - Downloading { version: VersionCheckType }, - Installing { version: VersionCheckType }, - Updated { version: VersionCheckType }, - Errored { error: Arc }, + Downloading { + version: VersionCheckType, + /// Download progress as a fraction in the range `0.0..=1.0`, or `None` + /// when the total download size is not yet known. + progress: Option, + }, + Installing { + version: VersionCheckType, + }, + Updated { + version: VersionCheckType, + }, + Errored { + error: Arc, + }, } impl PartialEq for AutoUpdateStatus { + // `progress` is deliberately not compared: two `Downloading` statuses for + // the same version are equal regardless of how far the download is. fn eq(&self, other: &Self) -> bool { match (self, other) { (AutoUpdateStatus::Idle, AutoUpdateStatus::Idle) => true, (AutoUpdateStatus::Checking, AutoUpdateStatus::Checking) => true, ( - AutoUpdateStatus::Downloading { version: v1 }, - AutoUpdateStatus::Downloading { version: v2 }, + AutoUpdateStatus::Downloading { version: v1, .. }, + AutoUpdateStatus::Downloading { version: v2, .. }, ) => v1 == v2, ( AutoUpdateStatus::Installing { version: v1 }, @@ -700,6 +716,7 @@ impl AutoUpdater { this.update(cx, |this, cx| { this.status = AutoUpdateStatus::Downloading { version: newer_version.clone(), + progress: None, }; cx.notify(); }); @@ -708,9 +725,27 @@ impl AutoUpdater { .await .context("Failed to create installer dir")?; let target_path = Self::target_path(&installer_dir).await?; - download_release(&target_path, fetched_release_data, client) - .await - .with_context(|| format!("Failed to download update to {}", target_path.display()))?; + let progress_entity = this.clone(); + let mut progress_cx = cx.clone(); + download_release( + &target_path, + fetched_release_data, + client, + move |progress| { + progress_entity.update(&mut progress_cx, |this, cx| { + if let AutoUpdateStatus::Downloading { + progress: current_progress, + .. + } = &mut this.status + { + *current_progress = progress; + cx.notify(); + } + }); + }, + ) + .await + .with_context(|| format!("Failed to download update to {}", target_path.display()))?; this.update(cx, |this, cx| { this.status = AutoUpdateStatus::Installing { @@ -989,6 +1024,7 @@ async fn download_release( target_path: &Path, release: ReleaseAsset, client: Arc, + mut on_progress: impl FnMut(Option), ) -> Result<()> { let mut target_file = File::create(&target_path).await?; @@ -998,7 +1034,40 @@ async fn download_release( "failed to download update: {:?}", response.status() ); - smol::io::copy(response.body_mut(), &mut target_file).await?; + + let total_bytes = response + .headers() + .get(http_client::http::header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .filter(|total_bytes| *total_bytes > 0); + + let mut downloaded_bytes: u64 = 0; + let mut last_reported_percent: Option = None; + let mut buffer = [0u8; 8192]; + let body = response.body_mut(); + loop { + let bytes_read = body.read(&mut buffer).await?; + if bytes_read == 0 { + break; + } + target_file.write_all(&buffer[..bytes_read]).await?; + downloaded_bytes += bytes_read as u64; + + if let Some(total_bytes) = total_bytes { + let fraction = (downloaded_bytes as f32 / total_bytes as f32).clamp(0.0, 1.0); + // Only report when the whole-number percentage changes to avoid notifying the UI on every chunk. + let percent = (fraction * 100.0) as u8; + if last_reported_percent != Some(percent) { + last_reported_percent = Some(percent); + on_progress(Some(fraction)); + } + } + } + target_file.flush().await?; + if total_bytes.is_some() && last_reported_percent != Some(100) { + on_progress(Some(1.0)); + } log::info!("downloaded update. path:{:?}", target_path); Ok(()) @@ -1297,7 +1366,8 @@ mod tests { assert_eq!( status, AutoUpdateStatus::Downloading { - version: VersionCheckType::Semantic(semver::Version::new(0, 100, 1)) + version: VersionCheckType::Semantic(semver::Version::new(0, 100, 1)), + progress: None, } ); @@ -1337,6 +1407,114 @@ mod tests { assert_eq!(std::fs::read_to_string(path).unwrap(), ""); } + #[gpui::test] + async fn test_download_release_reports_progress(cx: &mut TestAppContext) { + cx.background_executor.allow_parking(); + + let body = vec![0u8; 20_000]; + let content_length = body.len(); + + let client = FakeHttpClient::create(move |_req| { + let body = body.clone(); + async move { + Ok(Response::builder() + .status(200) + .header( + http_client::http::header::CONTENT_LENGTH, + body.len().to_string(), + ) + .body(body.into()) + .unwrap()) + } + }); + + let temp_dir = tempdir().unwrap(); + let target_path = temp_dir.path().join("zed-download"); + let release = ReleaseAsset { + version: "1.0.0".to_string(), + url: "https://test.example/download".to_string(), + }; + + let reported = Rc::new(std::cell::RefCell::new(Vec::::new())); + download_release(&target_path, release, client, { + let reported = reported.clone(); + move |fraction| { + if let Some(fraction) = fraction { + reported.borrow_mut().push(fraction); + } + } + }) + .await + .unwrap(); + + let reported = reported.borrow(); + assert!( + reported.len() >= 2, + "expected progress to be reported across multiple reads, got {reported:?}" + ); + assert_eq!( + reported.last().copied(), + Some(1.0), + "download should finish at 100%" + ); + for fraction in reported.iter() { + assert!( + (0.0..=1.0).contains(fraction), + "progress {fraction} out of range" + ); + } + for pair in reported.windows(2) { + assert!( + pair[0] <= pair[1], + "progress must not decrease: {reported:?}" + ); + } + + let downloaded_len = std::fs::metadata(&target_path).unwrap().len(); + assert_eq!(downloaded_len, content_length as u64); + } + + #[gpui::test] + async fn test_download_release_without_content_length_reports_no_progress( + cx: &mut TestAppContext, + ) { + cx.background_executor.allow_parking(); + + let body = vec![0u8; 20_000]; + let content_length = body.len(); + + let client = FakeHttpClient::create(move |_req| { + let body = body.clone(); + async move { Ok(Response::builder().status(200).body(body.into()).unwrap()) } + }); + + let temp_dir = tempdir().unwrap(); + let target_path = temp_dir.path().join("zed-download"); + let release = ReleaseAsset { + version: "1.0.0".to_string(), + url: "https://test.example/download".to_string(), + }; + + let reported = Rc::new(std::cell::RefCell::new(Vec::>::new())); + download_release(&target_path, release, client, { + let reported = reported.clone(); + move |fraction| { + reported.borrow_mut().push(fraction); + } + }) + .await + .unwrap(); + + assert!( + reported.borrow().is_empty(), + "progress should not be reported when the total size is unknown, got {:?}", + reported.borrow() + ); + + let downloaded_len = std::fs::metadata(&target_path).unwrap().len(); + assert_eq!(downloaded_len, content_length as u64); + } + #[test] fn test_stable_does_not_update_when_fetched_version_is_not_higher() { let release_channel = ReleaseChannel::Stable; diff --git a/crates/title_bar/src/update_version.rs b/crates/title_bar/src/update_version.rs index 2ca96b5ac0e..0c7db0d165f 100644 --- a/crates/title_bar/src/update_version.rs +++ b/crates/title_bar/src/update_version.rs @@ -43,6 +43,7 @@ impl UpdateVersion { AutoUpdateStatus::Idle => AutoUpdateStatus::Checking, AutoUpdateStatus::Checking => AutoUpdateStatus::Downloading { version: VersionCheckType::Semantic(Version::new(1, 99, 0)), + progress: Some(0.5), }, AutoUpdateStatus::Downloading { .. } => AutoUpdateStatus::Installing { version: VersionCheckType::Semantic(Version::new(1, 99, 0)), @@ -85,9 +86,9 @@ impl Render for UpdateVersion { AutoUpdateStatus::Checking if self.update_check_type.is_manual() => { UpdateButton::checking().into_any_element() } - AutoUpdateStatus::Downloading { version } => { + AutoUpdateStatus::Downloading { version, progress } => { let version = Self::version_tooltip_message(&version); - UpdateButton::downloading(version).into_any_element() + UpdateButton::downloading(version, *progress).into_any_element() } AutoUpdateStatus::Installing { version } => { let version = Self::version_tooltip_message(&version); diff --git a/crates/ui/src/components/collab/update_button.rs b/crates/ui/src/components/collab/update_button.rs index c0e74867cc6..38d6a169341 100644 --- a/crates/ui/src/components/collab/update_button.rs +++ b/crates/ui/src/components/collab/update_button.rs @@ -1,6 +1,10 @@ use gpui::{AnyElement, ClickEvent, prelude::*}; -use crate::{ButtonLike, CommonAnimationExt, Tooltip, prelude::*}; +use crate::{ButtonLike, CircularProgress, CommonAnimationExt, Tooltip, prelude::*}; + +const LOAD_CIRCLE_GLYPH_VIEWBOX: f32 = 16.0; +const LOAD_CIRCLE_GLYPH_STROKE_WIDTH: f32 = 1.2; +const LOAD_CIRCLE_GLYPH_RADIUS: f32 = 5.0; /// A button component displayed in the title bar to show auto-update status. #[derive(IntoElement, RegisterComponent)] @@ -12,6 +16,7 @@ pub struct UpdateButton { tooltip: Option, disabled: bool, show_dismiss: bool, + progress: Option, on_click: Option>, on_dismiss: Option>, } @@ -26,6 +31,7 @@ impl UpdateButton { tooltip: None, disabled: false, show_dismiss: false, + progress: None, on_click: None, on_dismiss: None, } @@ -78,15 +84,21 @@ impl UpdateButton { self } + pub fn progress(mut self, progress: impl Into>) -> Self { + self.progress = progress.into(); + self + } + pub fn checking() -> Self { Self::new(IconName::LoadCircle, "Checking for Zed Updates…") .icon_animate(true) .disabled(true) } - pub fn downloading(version: impl Into) -> Self { + pub fn downloading(version: impl Into, progress: Option) -> Self { Self::new(IconName::Download, "Downloading Zed Update…") .tooltip(version) + .progress(progress) .disabled(true) } @@ -112,24 +124,44 @@ impl UpdateButton { } impl RenderOnce for UpdateButton { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let border_color = if self.disabled { cx.theme().colors().border } else { cx.theme().colors().text.opacity(0.15) }; - let icon = Icon::new(self.icon) - .size(IconSize::XSmall) - .when_some(self.icon_color, |this, color| this.color(color)); - let icon_element = if self.icon_animate { - icon.with_rotate_animation(2).into_any_element() + let icon_element = if let Some(progress) = self.progress { + let progress = progress.clamp(0.0, 1.0); + let icon_box = IconSize::XSmall.rems().to_pixels(window.rem_size()); + let progress_color = Color::Default.color(cx); + CircularProgress::new(progress, 1.0, icon_box, cx) + .stroke_width( + icon_box * (LOAD_CIRCLE_GLYPH_STROKE_WIDTH / LOAD_CIRCLE_GLYPH_VIEWBOX), + ) + .radius(icon_box * (LOAD_CIRCLE_GLYPH_RADIUS / LOAD_CIRCLE_GLYPH_VIEWBOX)) + .bg_color(progress_color.opacity(0.2)) + .progress_color(progress_color) + .into_any_element() } else { - icon.into_any_element() + let icon = Icon::new(self.icon) + .size(IconSize::XSmall) + .when_some(self.icon_color, |this, color| this.color(color)); + if self.icon_animate { + icon.with_rotate_animation(2).into_any_element() + } else { + icon.into_any_element() + } }; let tooltip = self.tooltip.clone(); + let label_row = h_flex() + .h_full() + .gap_1() + .child(icon_element) + .child(Label::new(self.message).size(LabelSize::Small)); + h_flex() .mr_2() .rounded_sm() @@ -137,13 +169,7 @@ impl RenderOnce for UpdateButton { .border_color(border_color) .child( ButtonLike::new("update-button") - .child( - h_flex() - .h_full() - .gap_1() - .child(icon_element) - .child(Label::new(self.message).size(LabelSize::Small)), - ) + .child(label_row) .when_some(tooltip, |this, tooltip| { this.tooltip(Tooltip::text(tooltip)) }) @@ -189,7 +215,7 @@ impl Component for UpdateButton { single_example("Checking", UpdateButton::checking().into_any_element()), single_example( "Downloading", - UpdateButton::downloading(version).into_any_element(), + UpdateButton::downloading(version, Some(0.45)).into_any_element(), ), single_example( "Installing", diff --git a/crates/ui/src/components/progress/circular_progress.rs b/crates/ui/src/components/progress/circular_progress.rs index 69f93b740cd..26e38258f94 100644 --- a/crates/ui/src/components/progress/circular_progress.rs +++ b/crates/ui/src/components/progress/circular_progress.rs @@ -11,6 +11,7 @@ pub struct CircularProgress { max_value: f32, size: Pixels, stroke_width: Pixels, + radius: Option, bg_color: Hsla, progress_color: Hsla, } @@ -22,6 +23,7 @@ impl CircularProgress { max_value, size, stroke_width: px(4.0), + radius: None, bg_color: cx.theme().colors().border_variant, progress_color: cx.theme().status().info, } @@ -51,6 +53,16 @@ impl CircularProgress { self } + /// Sets the ring radius explicitly, decoupling it from the layout box. + /// + /// By default the radius is derived from the size and stroke width so + /// the ring fills the box. Set it explicitly to draw a smaller ring + /// inside the box, e.g. to match the padding of an icon glyph. + pub fn radius(mut self, radius: Pixels) -> Self { + self.radius = Some(radius); + self + } + /// Sets the background circle color. pub fn bg_color(mut self, color: Hsla) -> Self { self.bg_color = color; @@ -69,6 +81,8 @@ impl RenderOnce for CircularProgress { let value = self.value; let max_value = self.max_value; let size = self.size; + let stroke_width = self.stroke_width; + let radius = self.radius.unwrap_or_else(|| (size / 2.0) - stroke_width); let bg_color = self.bg_color; let progress_color = self.progress_color; @@ -80,9 +94,6 @@ impl RenderOnce for CircularProgress { let center_x = bounds.origin.x + bounds.size.width / 2.0; let center_y = bounds.origin.y + bounds.size.height / 2.0; - let stroke_width = self.stroke_width; - let radius = (size / 2.0) - stroke_width; - // Draw background circle (full 360 degrees) let mut bg_builder = PathBuilder::stroke(stroke_width); From a91c8aa7d71215bff8c4bed29085a7cff7bd80ad Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Fri, 3 Jul 2026 10:27:48 +0200 Subject: [PATCH 043/197] Remove more storybook leftovers (#60337) Release Notes: - N/A --- .github/CODEOWNERS.hold | 1 - assets/keymaps/storybook.json | 33 --------------------------------- script/check-keymaps | 1 - script/storybook | 7 ------- 4 files changed, 42 deletions(-) delete mode 100644 assets/keymaps/storybook.json delete mode 100755 script/storybook diff --git a/.github/CODEOWNERS.hold b/.github/CODEOWNERS.hold index 0e6ab04228d..fea437d4ff9 100644 --- a/.github/CODEOWNERS.hold +++ b/.github/CODEOWNERS.hold @@ -304,7 +304,6 @@ /crates/picker/ @zed-industries/ui-team /crates/refineable/ @zed-industries/ui-team /crates/story/ @zed-industries/ui-team -/crates/storybook/ @zed-industries/ui-team /crates/svg_preview/ @zed-industries/ui-team /crates/tab_switcher/ @zed-industries/ui-team /crates/theme/ @zed-industries/ui-team diff --git a/assets/keymaps/storybook.json b/assets/keymaps/storybook.json deleted file mode 100644 index 432bdc7004a..00000000000 --- a/assets/keymaps/storybook.json +++ /dev/null @@ -1,33 +0,0 @@ -[ - // Standard macOS bindings - { - "bindings": { - "home": "menu::SelectFirst", - "shift-pageup": "menu::SelectFirst", - "pageup": "menu::SelectFirst", - "cmd-up": "menu::SelectFirst", - "end": "menu::SelectLast", - "shift-pagedown": "menu::SelectLast", - "pagedown": "menu::SelectLast", - "cmd-down": "menu::SelectLast", - "tab": "menu::SelectNext", - "ctrl-n": "menu::SelectNext", - "down": "menu::SelectNext", - "shift-tab": "menu::SelectPrevious", - "ctrl-p": "menu::SelectPrevious", - "up": "menu::SelectPrevious", - "enter": "menu::Confirm", - "ctrl-enter": "menu::SecondaryConfirm", - "cmd-enter": "menu::SecondaryConfirm", - "ctrl-escape": "menu::Cancel", - "cmd-escape": "menu::Cancel", - "ctrl-c": "menu::Cancel", - "escape": "menu::Cancel", - "cmd-q": "storybook::Quit", - "backspace": "editor::Backspace", - "delete": "editor::Delete", - "left": "editor::MoveLeft", - "right": "editor::MoveRight", - }, - }, -] diff --git a/script/check-keymaps b/script/check-keymaps index abda2118f84..e72dec7e92a 100755 --- a/script/check-keymaps +++ b/script/check-keymaps @@ -5,7 +5,6 @@ set -euo pipefail pattern='cmd-' result=$(git grep --no-color --line-number --fixed-strings -e "$pattern" -- \ 'assets/keymaps/' \ - ':(exclude)assets/keymaps/storybook.json' \ ':(exclude)assets/keymaps/default-macos.json' \ ':(exclude)assets/keymaps/specific-overrides-macos.json' \ ':(exclude)assets/keymaps/macos/*.json' || true) diff --git a/script/storybook b/script/storybook deleted file mode 100755 index 20a81008d1c..00000000000 --- a/script/storybook +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -if [ -z "$1" ]; then - cargo run -p storybook -else - cargo run -p storybook -- "components/$1" -fi From 98ddc3ae2efef817d71f40f8ead59b87e5407d68 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Fri, 3 Jul 2026 11:38:07 +0200 Subject: [PATCH 044/197] Update openssl dependencies (#60342) Release Notes: - N/A --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bc57517ab75..e2fcdbf54f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12368,9 +12368,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.79" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ "bitflags 2.10.0", "cfg-if", @@ -12405,9 +12405,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.115" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", From 59185f5a70b4ed7015de301db494b6e1032e9a09 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Fri, 3 Jul 2026 05:50:42 -0400 Subject: [PATCH 045/197] livekit_api: Fix LiveKit token revocation timestamps (#60157) LiveKit Cloud rejects room-join tokens as revoked when their `nbf` predates a participant revocation. Zed generated those LiveKit JWTs with `nbf: 0`, so a fresh participant token minted after stale connection cleanup could still appear older than the cleanup and leave a user joined at the collab layer without audio or screen sharing. This sets `nbf` to the issuance time for room-join tokens while leaving admin/API tokens unchanged, and adds regression coverage at the token, mock LiveKit, and channel rejoin layers. Closes FR-83 Release Notes: - Fixed calls getting stuck without audio or screen sharing after restarting Zed and rejoining a channel. --- .../collab/tests/integration/channel_tests.rs | 89 ++++++ .../collab/tests/integration/test_server.rs | 12 +- crates/livekit_api/Cargo.toml | 3 + crates/livekit_api/src/livekit_api.rs | 32 +- crates/livekit_api/src/token.rs | 190 +++++++++++- crates/livekit_client/Cargo.toml | 3 +- crates/livekit_client/src/test.rs | 276 ++++++++++++++++-- 7 files changed, 570 insertions(+), 35 deletions(-) diff --git a/crates/collab/tests/integration/channel_tests.rs b/crates/collab/tests/integration/channel_tests.rs index 329478819e4..7b20620082d 100644 --- a/crates/collab/tests/integration/channel_tests.rs +++ b/crates/collab/tests/integration/channel_tests.rs @@ -589,6 +589,95 @@ async fn test_channel_room( ); } +#[gpui::test] +async fn test_rejoining_channel_after_stale_connection_cleanup_connects_livekit( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_a2: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + + let channel_id = server + .make_channel("zed", None, (&client_a, cx_a), &mut [(&client_b, cx_b)]) + .await; + + let active_call_a = cx_a.read(ActiveCall::global); + active_call_a + .update(cx_a, |active_call, cx| { + active_call.join_channel(channel_id, cx) + }) + .await + .unwrap(); + + let active_call_b = cx_b.read(ActiveCall::global); + active_call_b + .update(cx_b, |active_call, cx| { + active_call.join_channel(channel_id, cx) + }) + .await + .unwrap(); + + executor.run_until_parked(); + + let old_room_a = + cx_a.read(|cx| active_call_a.read_with(cx, |call, _| call.room().unwrap().clone())); + cx_a.read(|cx| old_room_a.read_with(cx, |room, cx| assert!(room.is_connected(cx)))); + + server.disconnect_client(client_a.peer_id().unwrap()); + executor.run_until_parked(); + server.advance_livekit_timestamp(); + + let client_a2 = server.create_client(cx_a2, "user_a").await; + let active_call_a2 = cx_a2.read(ActiveCall::global); + active_call_a2 + .update(cx_a2, |active_call, cx| { + active_call.join_channel(channel_id, cx) + }) + .await + .unwrap(); + + executor.run_until_parked(); + + let room_a2 = + cx_a2.read(|cx| active_call_a2.read_with(cx, |call, _| call.room().unwrap().clone())); + cx_a2.read(|cx| room_a2.read_with(cx, |room, cx| assert!(room.is_connected(cx)))); + assert_eq!( + room_participants(&room_a2, cx_a2), + RoomParticipants { + remote: vec!["user_b".to_string()], + pending: vec![] + } + ); + + let room_b = + cx_b.read(|cx| active_call_b.read_with(cx, |call, _| call.room().unwrap().clone())); + cx_b.read(|cx| room_b.read_with(cx, |room, cx| assert!(room.is_connected(cx)))); + assert_eq!( + room_participants(&room_b, cx_b), + RoomParticipants { + remote: vec!["user_a".to_string()], + pending: vec![] + } + ); + + cx_a2.read(|cx| { + client_a2.channel_store().read_with(cx, |channels, _| { + let mut participant_ids = channels + .channel_participants(channel_id) + .iter() + .map(|participant| participant.legacy_id) + .collect::>(); + participant_ids.sort_unstable(); + let mut expected_ids = vec![client_a2.user_id().unwrap(), client_b.user_id().unwrap()]; + expected_ids.sort_unstable(); + assert_eq!(participant_ids, expected_ids); + }) + }); +} + #[gpui::test] async fn test_channel_jumping(executor: BackgroundExecutor, cx_a: &mut TestAppContext) { let mut server = TestServer::start(executor.clone()).await; diff --git a/crates/collab/tests/integration/test_server.rs b/crates/collab/tests/integration/test_server.rs index 62169c9a938..3434674e630 100644 --- a/crates/collab/tests/integration/test_server.rs +++ b/crates/collab/tests/integration/test_server.rs @@ -48,7 +48,7 @@ use std::{ use util::path; use workspace::{MultiWorkspace, Workspace, WorkspaceStore}; -use livekit_client::test::TestServer as LivekitTestServer; +use livekit_client::test::{ManualUnixTimestampSource, TestServer as LivekitTestServer}; use crate::db_tests::TestDb; @@ -56,6 +56,7 @@ pub struct TestServer { pub app_state: Arc, pub test_livekit_server: Arc, pub test_db: TestDb, + livekit_timestamp_source: Arc, server: Arc, next_github_user_id: i32, connection_killers: Arc>>>, @@ -96,11 +97,13 @@ impl TestServer { TestDb::sqlite(deterministic.clone()) }; let livekit_server_id = NEXT_LIVEKIT_SERVER_ID.fetch_add(1, SeqCst); - let livekit_server = LivekitTestServer::create( + let livekit_timestamp_source = Arc::new(ManualUnixTimestampSource::new(1_234_567)); + let livekit_server = LivekitTestServer::create_with_timestamp_source( format!("http://livekit.{}.test", livekit_server_id), format!("devkey-{}", livekit_server_id), format!("secret-{}", livekit_server_id), deterministic.clone(), + livekit_timestamp_source.clone(), ) .unwrap(); let executor = Executor::Deterministic(deterministic.clone()); @@ -121,10 +124,15 @@ impl TestServer { forbid_connections: Default::default(), next_github_user_id: 0, test_db, + livekit_timestamp_source, test_livekit_server: livekit_server, } } + pub fn advance_livekit_timestamp(&self) { + self.livekit_timestamp_source.advance(); + } + pub async fn start2( cx_a: &mut TestAppContext, cx_b: &mut TestAppContext, diff --git a/crates/livekit_api/Cargo.toml b/crates/livekit_api/Cargo.toml index 2b2438c25e6..80ba67d9941 100644 --- a/crates/livekit_api/Cargo.toml +++ b/crates/livekit_api/Cargo.toml @@ -13,6 +13,9 @@ workspace = true path = "src/livekit_api.rs" doctest = false +[features] +test-support = [] + [dependencies] anyhow.workspace = true async-trait.workspace = true diff --git a/crates/livekit_api/src/livekit_api.rs b/crates/livekit_api/src/livekit_api.rs index 745f511b12e..250f56b3fd9 100644 --- a/crates/livekit_api/src/livekit_api.rs +++ b/crates/livekit_api/src/livekit_api.rs @@ -31,10 +31,25 @@ pub struct LiveKitClient { url: Arc, key: Arc, secret: Arc, + timestamp_source: Arc, } impl LiveKitClient { - pub fn new(mut url: String, key: String, secret: String) -> Self { + pub fn new(url: String, key: String, secret: String) -> Self { + Self::new_with_timestamp_source( + url, + key, + secret, + Arc::new(token::SystemUnixTimestampSource), + ) + } + + pub(crate) fn new_with_timestamp_source( + mut url: String, + key: String, + secret: String, + timestamp_source: Arc, + ) -> Self { if url.ends_with('/') { url.pop(); } @@ -47,6 +62,7 @@ impl LiveKitClient { url: url.into(), key: key.into(), secret: secret.into(), + timestamp_source, } } @@ -61,7 +77,13 @@ impl LiveKitClient { Res: Default + Message, { let client = self.http.clone(); - let token = token::create(&self.key, &self.secret, None, grant); + let token = token::create_with_timestamp_source( + &self.key, + &self.secret, + None, + grant, + self.timestamp_source.as_ref(), + ); let url = format!("{}/{}", self.url, path); log::info!("Request {}: {:?}", url, body); async move { @@ -163,20 +185,22 @@ impl Client for LiveKitClient { } fn room_token(&self, room: &str, identity: &str) -> Result { - token::create( + token::create_with_timestamp_source( &self.key, &self.secret, Some(identity), token::VideoGrant::to_join(room), + self.timestamp_source.as_ref(), ) } fn guest_token(&self, room: &str, identity: &str) -> Result { - token::create( + token::create_with_timestamp_source( &self.key, &self.secret, Some(identity), token::VideoGrant::for_guest(room), + self.timestamp_source.as_ref(), ) } } diff --git a/crates/livekit_api/src/token.rs b/crates/livekit_api/src/token.rs index 6f12d788551..5229a83bb6e 100644 --- a/crates/livekit_api/src/token.rs +++ b/crates/livekit_api/src/token.rs @@ -1,14 +1,25 @@ -use anyhow::Result; +use anyhow::{Context as _, Result}; use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; use std::{ borrow::Cow, - ops::Add, time::{Duration, SystemTime, UNIX_EPOCH}, }; const DEFAULT_TTL: Duration = Duration::from_secs(6 * 60 * 60); // 6 hours +pub trait UnixTimestampSource: Send + Sync { + fn unix_timestamp(&self) -> Result; +} + +pub struct SystemUnixTimestampSource; + +impl UnixTimestampSource for SystemUnixTimestampSource { + fn unix_timestamp(&self) -> Result { + Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs()) + } +} + #[derive(Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ClaimGrants<'a> { @@ -73,22 +84,55 @@ pub fn create( identity: Option<&str>, video_grant: VideoGrant, ) -> Result { - if video_grant.room_join.is_some() && identity.is_none() { + create_with_timestamp_source( + api_key, + secret_key, + identity, + video_grant, + &SystemUnixTimestampSource, + ) +} + +pub fn create_with_timestamp_source( + api_key: &str, + secret_key: &str, + identity: Option<&str>, + video_grant: VideoGrant, + timestamp_source: &dyn UnixTimestampSource, +) -> Result { + let issued_at = timestamp_source.unix_timestamp()?; + create_with_issued_at(api_key, secret_key, identity, video_grant, issued_at) +} + +fn create_with_issued_at( + api_key: &str, + secret_key: &str, + identity: Option<&str>, + video_grant: VideoGrant, + issued_at: u64, +) -> Result { + let room_join = video_grant.room_join.unwrap_or(false); + if room_join && identity.is_none() { anyhow::bail!("identity is required for room_join grant, but it is none"); } - let now = SystemTime::now(); + let expires_at = issued_at + .checked_add(DEFAULT_TTL.as_secs()) + .context("token expiration overflow")?; + let not_before = if room_join { + // LiveKit Cloud applies participant revocations by comparing the + // revocation timestamp to room-join token `nbf`. + issued_at + } else { + 0 + }; let claims = ClaimGrants { iss: Cow::Borrowed(api_key), sub: identity.map(Cow::Borrowed), - iat: now.duration_since(UNIX_EPOCH).unwrap().as_secs(), - exp: now - .add(DEFAULT_TTL) - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(), - nbf: 0, + iat: issued_at, + exp: expires_at, + nbf: not_before, jwtid: identity.map(Cow::Borrowed), video: video_grant, }; @@ -108,3 +152,127 @@ pub fn validate<'a>(token: &'a str, secret_key: &str) -> Result> Ok(token.claims) } + +#[cfg(any(test, feature = "test-support"))] +pub fn validate_with_timestamp_source<'a>( + token: &'a str, + secret_key: &str, + timestamp_source: &dyn UnixTimestampSource, +) -> Result> { + let mut validation = Validation::default(); + validation.validate_exp = false; + validation.validate_nbf = false; + let token: jsonwebtoken::TokenData> = jsonwebtoken::decode( + token, + &DecodingKey::from_secret(secret_key.as_ref()), + &validation, + )?; + let claims = token.claims; + let timestamp = timestamp_source.unix_timestamp()?; + + anyhow::ensure!(claims.nbf <= timestamp, "token is not yet valid"); + anyhow::ensure!(claims.exp > timestamp, "token has expired"); + + Ok(claims) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Client as _, LiveKitClient}; + use std::sync::Arc; + + const ISSUED_AT: u64 = 1_234_567; + + struct FixedUnixTimestampSource(u64); + + impl UnixTimestampSource for FixedUnixTimestampSource { + fn unix_timestamp(&self) -> Result { + Ok(self.0) + } + } + + #[test] + fn token_not_before_matches_issue_time() -> Result<()> { + let token = create_with_timestamp_source( + "api-key", + "secret-key", + Some("participant"), + VideoGrant::to_join("room"), + &FixedUnixTimestampSource(ISSUED_AT), + )?; + + assert_claims_timestamp(&token, ISSUED_AT, ISSUED_AT)?; + Ok(()) + } + + #[test] + fn room_token_not_before_matches_issue_time() -> Result<()> { + let client = LiveKitClient::new_with_timestamp_source( + "http://livekit.test".into(), + "api-key".into(), + "secret-key".into(), + Arc::new(FixedUnixTimestampSource(ISSUED_AT)), + ); + let token = client.room_token("room", "participant")?; + + let claims = assert_claims_timestamp(&token, ISSUED_AT, ISSUED_AT)?; + assert_eq!(claims.video.room_join, Some(true)); + assert_eq!(claims.video.can_publish, Some(true)); + assert_eq!(claims.video.can_subscribe, Some(true)); + + Ok(()) + } + + #[test] + fn guest_token_not_before_matches_issue_time() -> Result<()> { + let client = LiveKitClient::new_with_timestamp_source( + "http://livekit.test".into(), + "api-key".into(), + "secret-key".into(), + Arc::new(FixedUnixTimestampSource(ISSUED_AT)), + ); + let token = client.guest_token("room", "participant")?; + + let claims = assert_claims_timestamp(&token, ISSUED_AT, ISSUED_AT)?; + assert_eq!(claims.video.room_join, Some(true)); + assert_eq!(claims.video.can_publish, Some(false)); + assert_eq!(claims.video.can_subscribe, Some(true)); + + Ok(()) + } + + #[test] + fn admin_token_not_before_remains_unset() -> Result<()> { + let token = create_with_timestamp_source( + "api-key", + "secret-key", + None, + VideoGrant::to_admin("room"), + &FixedUnixTimestampSource(ISSUED_AT), + )?; + + let claims = assert_claims_timestamp(&token, ISSUED_AT, 0)?; + assert_eq!(claims.video.room_admin, Some(true)); + + Ok(()) + } + + fn assert_claims_timestamp( + token: &str, + issued_at: u64, + expected_not_before: u64, + ) -> Result> { + let claims = validate_with_timestamp_source( + token, + "secret-key", + &FixedUnixTimestampSource(issued_at), + )?; + + assert_eq!(claims.iat, issued_at); + assert_eq!(claims.nbf, expected_not_before); + assert_eq!(claims.exp, issued_at + DEFAULT_TTL.as_secs()); + + Ok(claims) + } +} diff --git a/crates/livekit_client/Cargo.toml b/crates/livekit_client/Cargo.toml index 42c13f094c1..a229d39ca8c 100644 --- a/crates/livekit_client/Cargo.toml +++ b/crates/livekit_client/Cargo.toml @@ -17,7 +17,7 @@ doctest = false name = "test_app" [features] -test-support = ["collections/test-support", "gpui/test-support"] +test-support = ["collections/test-support", "gpui/test-support", "livekit_api/test-support"] [dependencies] anyhow.workspace = true @@ -65,6 +65,7 @@ objc.workspace = true collections = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } gpui_platform.workspace = true +livekit_api = { workspace = true, features = ["test-support"] } simplelog.workspace = true [build-dependencies] diff --git a/crates/livekit_client/src/test.rs b/crates/livekit_client/src/test.rs index 955f92dc19d..d742dd06617 100644 --- a/crates/livekit_client/src/test.rs +++ b/crates/livekit_client/src/test.rs @@ -57,6 +57,25 @@ pub struct TestServer { pub secret_key: String, rooms: Mutex>, executor: BackgroundExecutor, + timestamp_source: Arc, +} + +pub struct ManualUnixTimestampSource(AtomicU64); + +impl ManualUnixTimestampSource { + pub fn new(timestamp: u64) -> Self { + Self(AtomicU64::new(timestamp)) + } + + pub fn advance(&self) { + self.0.fetch_add(1, SeqCst); + } +} + +impl token::UnixTimestampSource for ManualUnixTimestampSource { + fn unix_timestamp(&self) -> Result { + Ok(self.0.load(SeqCst)) + } } impl TestServer { @@ -65,6 +84,22 @@ impl TestServer { api_key: String, secret_key: String, executor: BackgroundExecutor, + ) -> Result> { + Self::create_with_timestamp_source( + url, + api_key, + secret_key, + executor, + Arc::new(token::SystemUnixTimestampSource), + ) + } + + pub fn create_with_timestamp_source( + url: String, + api_key: String, + secret_key: String, + executor: BackgroundExecutor, + timestamp_source: Arc, ) -> Result> { let mut servers = SERVERS.lock(); if let BTreeEntry::Vacant(e) = servers.entry(url.clone()) { @@ -74,6 +109,7 @@ impl TestServer { secret_key, rooms: Default::default(), executor, + timestamp_source, }); e.insert(server.clone()); Ok(server) @@ -104,6 +140,20 @@ impl TestServer { } } + #[cfg(any(test, feature = "test-support"))] + fn validate_token<'a>(&self, token: &'a str) -> Result> { + token::validate_with_timestamp_source( + token, + &self.secret_key, + self.timestamp_source.as_ref(), + ) + } + + #[cfg(not(any(test, feature = "test-support")))] + fn validate_token<'a>(&self, token: &'a str) -> Result> { + token::validate(token, &self.secret_key) + } + pub async fn create_room(&self, room: String) -> Result<()> { self.simulate_random_delay().await; @@ -129,11 +179,19 @@ impl TestServer { async fn join_room(&self, token: String, client_room: Room) -> Result { self.simulate_random_delay().await; - let claims = livekit_api::token::validate(&token, &self.secret_key)?; - let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); - let room_name = claims.video.room.unwrap(); + let claims = self.validate_token(&token)?; + let identity = ParticipantIdentity( + claims + .sub + .context("missing participant identity")? + .to_string(), + ); + let room_name = claims.video.room.context("missing room name")?.to_string(); let mut server_rooms = self.rooms.lock(); - let room = (*server_rooms).entry(room_name.to_string()).or_default(); + let room = (*server_rooms).entry(room_name.clone()).or_default(); + if let Some(revoked_before) = room.token_revocations.get(&identity) { + anyhow::ensure!(claims.nbf >= *revoked_before, "invalid token: revoked"); + } if let Entry::Vacant(e) = room.client_rooms.entry(identity.clone()) { for server_track in &room.video_tracks { @@ -192,7 +250,7 @@ impl TestServer { async fn leave_room(&self, token: String) -> Result<()> { self.simulate_random_delay().await; - let claims = livekit_api::token::validate(&token, &self.secret_key)?; + let claims = self.validate_token(&token)?; let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); let room_name = claims.video.room.unwrap(); let mut server_rooms = self.rooms.lock(); @@ -209,7 +267,7 @@ impl TestServer { &self, token: String, ) -> Result> { - let claims = livekit_api::token::validate(&token, &self.secret_key)?; + let claims = self.validate_token(&token)?; let local_identity = ParticipantIdentity(claims.sub.unwrap().to_string()); let room_name = claims.video.room.unwrap().to_string(); @@ -244,14 +302,25 @@ impl TestServer { identity: ParticipantIdentity, ) -> Result<()> { self.simulate_random_delay().await; + let revoked_before = self.timestamp_source.unix_timestamp()?; let mut server_rooms = self.rooms.lock(); let room = server_rooms .get_mut(&room_name) .with_context(|| format!("room {room_name} does not exist"))?; - room.client_rooms + let removed_room = room + .client_rooms .remove(&identity) .with_context(|| format!("participant {identity:?} did not join room {room_name:?}"))?; + room.token_revocations.insert(identity, revoked_before); + let mut removed_room = removed_room.0.lock(); + removed_room.connection_state = ConnectionState::Disconnected; + removed_room + .updates_tx + .blocking_send(RoomEvent::Disconnected { + reason: "PARTICIPANT_REMOVED", + }) + .ok(); Ok(()) } @@ -262,13 +331,18 @@ impl TestServer { permission: proto::ParticipantPermission, ) -> Result<()> { self.simulate_random_delay().await; + let revoked_before = self.timestamp_source.unix_timestamp()?; let mut server_rooms = self.rooms.lock(); let room = server_rooms .get_mut(&room_name) .with_context(|| format!("room {room_name} does not exist"))?; + let identity = ParticipantIdentity(identity); room.participant_permissions - .insert(ParticipantIdentity(identity), permission); + .insert(identity.clone(), permission); + // Permission changes in LiveKit Cloud invalidate existing participant + // tokens, so the mock needs to reject tokens minted before the update. + room.token_revocations.insert(identity, revoked_before); Ok(()) } @@ -298,7 +372,7 @@ impl TestServer { ) -> Result { self.simulate_random_delay().await; - let claims = livekit_api::token::validate(&token, &self.secret_key)?; + let claims = self.validate_token(&token)?; let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); let room_name = claims.video.room.unwrap(); @@ -362,7 +436,7 @@ impl TestServer { ) -> Result { self.simulate_random_delay().await; - let claims = livekit_api::token::validate(&token, &self.secret_key)?; + let claims = self.validate_token(&token)?; let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); let room_name = claims.video.room.unwrap(); @@ -421,7 +495,7 @@ impl TestServer { } pub(crate) async fn unpublish_track(&self, token: String, track_sid: &TrackSid) -> Result<()> { - let claims = livekit_api::token::validate(&token, &self.secret_key)?; + let claims = self.validate_token(&token)?; let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); let room_name = claims.video.room.unwrap(); @@ -503,7 +577,7 @@ impl TestServer { track_sid: &TrackSid, muted: bool, ) -> Result<()> { - let claims = livekit_api::token::validate(token, &self.secret_key)?; + let claims = self.validate_token(token)?; let room_name = claims.video.room.unwrap(); let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); let mut server_rooms = self.rooms.lock(); @@ -557,7 +631,7 @@ impl TestServer { } pub(crate) fn is_track_muted(&self, token: &str, track_sid: &TrackSid) -> Option { - let claims = livekit_api::token::validate(token, &self.secret_key).ok()?; + let claims = self.validate_token(token).ok()?; let room_name = claims.video.room.unwrap(); let mut server_rooms = self.rooms.lock(); @@ -572,7 +646,7 @@ impl TestServer { } pub(crate) fn video_tracks(&self, token: String) -> Result> { - let claims = livekit_api::token::validate(&token, &self.secret_key)?; + let claims = self.validate_token(&token)?; let room_name = claims.video.room.unwrap(); let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); @@ -595,7 +669,7 @@ impl TestServer { } pub(crate) fn audio_tracks(&self, token: String) -> Result> { - let claims = livekit_api::token::validate(&token, &self.secret_key)?; + let claims = self.validate_token(&token)?; let room_name = claims.video.room.unwrap(); let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); @@ -629,6 +703,7 @@ struct TestServerRoom { video_tracks: Vec>, audio_tracks: Vec>, participant_permissions: HashMap, + token_revocations: HashMap, } #[derive(Debug)] @@ -690,21 +765,23 @@ impl livekit_api::Client for TestApiClient { fn room_token(&self, room: &str, identity: &str) -> Result { let server = TestServer::get(&self.url)?; - token::create( + token::create_with_timestamp_source( &server.api_key, &server.secret_key, Some(identity), token::VideoGrant::to_join(room), + server.timestamp_source.as_ref(), ) } fn guest_token(&self, room: &str, identity: &str) -> Result { let server = TestServer::get(&self.url)?; - token::create( + token::create_with_timestamp_source( &server.api_key, &server.secret_key, Some(identity), token::VideoGrant::for_guest(room), + server.timestamp_source.as_ref(), ) } } @@ -853,3 +930,168 @@ impl WeakRoom { self.0.upgrade().map(Room) } } + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + use livekit_api::Client as _; + use std::{ops::Deref, sync::atomic::AtomicUsize}; + + struct TestServerGuard { + server: Arc, + timestamp_source: Arc, + } + + impl TestServerGuard { + fn advance_timestamp(&self) { + self.timestamp_source.advance(); + } + } + + impl Deref for TestServerGuard { + type Target = TestServer; + + fn deref(&self) -> &Self::Target { + self.server.as_ref() + } + } + + impl Drop for TestServerGuard { + fn drop(&mut self) { + self.server.teardown().ok(); + } + } + + fn create_test_server(name: &str, executor: BackgroundExecutor) -> TestServerGuard { + static NEXT_SERVER_ID: AtomicUsize = AtomicUsize::new(0); + let server_id = NEXT_SERVER_ID.fetch_add(1, SeqCst); + let timestamp_source = Arc::new(ManualUnixTimestampSource::new(1_234_567)); + let server = TestServer::create_with_timestamp_source( + format!("http://livekit-{name}-{server_id}.test"), + format!("api-key-{server_id}"), + format!("secret-key-{server_id}"), + executor, + timestamp_source.clone(), + ) + .expect("create LiveKit test server"); + TestServerGuard { + server, + timestamp_source, + } + } + + async fn assert_token_was_revoked(server: &TestServer, token: String, cx: &mut TestAppContext) { + match Room::connect(server.url.clone(), token, &mut cx.to_async()).await { + Ok(_) => panic!("revoked token unexpectedly connected"), + Err(error) => { + let error = format!("{error:#}"); + assert!( + error.contains("invalid token: revoked"), + "expected revoked token error, got {error}" + ); + } + } + } + + #[gpui::test] + async fn token_created_after_participant_removal_can_join( + executor: BackgroundExecutor, + cx: &mut TestAppContext, + ) { + let server = create_test_server("room-token", executor); + server + .create_room("room".into()) + .await + .expect("create LiveKit test room"); + let api_client = server.create_api_client(); + + let initial_token = api_client + .room_token("room", "participant") + .expect("create initial room token"); + let (initial_room, _) = Room::connect( + server.url.clone(), + initial_token.clone(), + &mut cx.to_async(), + ) + .await + .expect("connect with initial room token"); + + server.advance_timestamp(); + api_client + .remove_participant("room".into(), "participant".into()) + .await + .expect("remove participant"); + + assert_eq!( + initial_room.connection_state(), + ConnectionState::Disconnected + ); + assert_token_was_revoked(&server, initial_token, cx).await; + + let fresh_token = api_client + .room_token("room", "participant") + .expect("create fresh room token"); + let (fresh_room, _) = Room::connect(server.url.clone(), fresh_token, &mut cx.to_async()) + .await + .expect("connect with fresh room token"); + + assert_eq!(fresh_room.connection_state(), ConnectionState::Connected); + } + + #[gpui::test] + async fn guest_token_created_after_permission_update_can_join( + executor: BackgroundExecutor, + cx: &mut TestAppContext, + ) { + let server = create_test_server("guest-token", executor); + server + .create_room("room".into()) + .await + .expect("create LiveKit test room"); + let api_client = server.create_api_client(); + + let initial_token = api_client + .guest_token("room", "participant") + .expect("create initial guest token"); + let (initial_room, _) = Room::connect( + server.url.clone(), + initial_token.clone(), + &mut cx.to_async(), + ) + .await + .expect("connect with initial guest token"); + + server.advance_timestamp(); + api_client + .update_participant( + "room".into(), + "participant".into(), + proto::ParticipantPermission { + can_subscribe: true, + can_publish: true, + can_publish_data: true, + hidden: false, + recorder: false, + }, + ) + .await + .expect("update participant permissions"); + assert_token_was_revoked(&server, initial_token, cx).await; + + server.disconnect_client("participant".into()).await; + assert_eq!( + initial_room.connection_state(), + ConnectionState::Disconnected + ); + + let fresh_token = api_client + .guest_token("room", "participant") + .expect("create fresh guest token"); + let (fresh_room, _) = Room::connect(server.url.clone(), fresh_token, &mut cx.to_async()) + .await + .expect("connect with fresh guest token"); + + assert_eq!(fresh_room.connection_state(), ConnectionState::Connected); + } +} From 616b76cd5912441676e5f015c084a9d57b6c0cfb Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Fri, 3 Jul 2026 12:10:41 +0200 Subject: [PATCH 046/197] Update Danger to 13.0.8 (#60346) Release Notes: - N/A --- script/danger/package.json | 2 +- script/danger/pnpm-lock.yaml | 63 ++++++++++++++++++------------------ 2 files changed, 33 insertions(+), 32 deletions(-) diff --git a/script/danger/package.json b/script/danger/package.json index b0c33a3f505..cf2782ad631 100644 --- a/script/danger/package.json +++ b/script/danger/package.json @@ -7,7 +7,7 @@ "danger": "danger" }, "devDependencies": { - "danger": "13.0.7", + "danger": "13.0.8", "danger-plugin-pr-hygiene": "0.7.1" } } diff --git a/script/danger/pnpm-lock.yaml b/script/danger/pnpm-lock.yaml index 197840ca3b8..3dc4722572b 100644 --- a/script/danger/pnpm-lock.yaml +++ b/script/danger/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: devDependencies: danger: - specifier: 13.0.7 - version: 13.0.7 + specifier: 13.0.8 + version: 13.0.8 danger-plugin-pr-hygiene: specifier: 0.7.1 version: 0.7.1 @@ -134,8 +134,8 @@ packages: danger-plugin-pr-hygiene@0.7.1: resolution: {integrity: sha512-ll070nNaL3OeO2nooYWflPE/CRKLeq8GiH2C68u5zM3gW4gepH89GhVv0sYNNGLx4cYwa1zZ/TuiYYhC49z06Q==} - danger@13.0.7: - resolution: {integrity: sha512-H7Syz9P3np7tgOjTYs1DDogjlknPWYwBIJXUTFIK5iFZOQ0b8irkUz5swOLFUmw7j0aKuybhwkXTcfyHFvRzCQ==} + danger@13.0.8: + resolution: {integrity: sha512-j2UCsgtcjTuftJ79+W9N8UGdIKBrCjTYqrjVf7rpqRaZBfi/VqKVvzWnTxFrj25Gt5YCwbRaY3+cG7tovV3Vtg==} engines: {node: '>=18'} hasBin: true @@ -166,8 +166,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} fast-json-patch@3.1.1: @@ -204,8 +204,8 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} - hasown@2.0.3: - resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} http-proxy-agent@7.0.2: @@ -338,8 +338,8 @@ packages: resolution: {integrity: sha512-rksPWtoZb2ZpT5OVgtmy0KHVM+Dca3iVwWY9ifwhcexfjebtgjg3wmrUt9PvJ59XIYBcknQeYHD8IAnVlh9lAw==} hasBin: true - qs@6.15.1: - resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} readline-sync@1.4.10: @@ -360,8 +360,8 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true @@ -377,8 +377,8 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} supports-color@10.2.2: @@ -389,8 +389,8 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - supports-hyperlinks@4.4.0: - resolution: {integrity: sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==} + supports-hyperlinks@4.5.0: + resolution: {integrity: sha512-ZW2OvfeCXrNTbLakPUzjQG922EeGCOteFSVoek5DKStTh898wf7zgtuFlzQN8HfZCxC3Eh02yJVrRW51hADf+w==} engines: {node: '>=20'} to-regex-range@5.0.1: @@ -424,12 +424,12 @@ snapshots: '@gitbeaker/core@38.12.1': dependencies: '@gitbeaker/requester-utils': 38.12.1 - qs: 6.15.1 + qs: 6.15.3 xcase: 2.0.1 '@gitbeaker/requester-utils@38.12.1': dependencies: - qs: 6.15.1 + qs: 6.15.3 xcase: 2.0.1 '@gitbeaker/rest@38.12.1': @@ -547,7 +547,7 @@ snapshots: danger-plugin-pr-hygiene@0.7.1: {} - danger@13.0.7: + danger@13.0.8: dependencies: '@gitbeaker/rest': 38.12.1 '@octokit/rest': 20.1.2 @@ -581,7 +581,7 @@ snapshots: readline-sync: 1.4.10 regenerator-runtime: 0.13.11 require-from-string: 2.0.2 - supports-hyperlinks: 4.4.0 + supports-hyperlinks: 4.5.0 transitivePeerDependencies: - encoding - supports-color @@ -606,7 +606,7 @@ snapshots: es-errors@1.3.0: {} - es-object-atoms@1.1.1: + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -623,18 +623,18 @@ snapshots: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 function-bind: 1.1.2 get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.3 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 gopd@1.2.0: {} @@ -644,7 +644,7 @@ snapshots: has-symbols@1.1.0: {} - hasown@2.0.3: + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -683,7 +683,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.7.4 + semver: 7.8.5 jwa@2.0.1: dependencies: @@ -762,9 +762,10 @@ snapshots: colors: 1.4.0 minimist: 1.2.8 - qs@6.15.1: + qs@6.15.3: dependencies: - side-channel: 1.1.0 + es-define-property: 1.0.1 + side-channel: 1.1.1 readline-sync@1.4.10: {} @@ -776,7 +777,7 @@ snapshots: safe-buffer@5.2.1: {} - semver@7.7.4: {} + semver@7.8.5: {} side-channel-list@1.0.1: dependencies: @@ -798,7 +799,7 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.1.0: + side-channel@1.1.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 @@ -812,7 +813,7 @@ snapshots: dependencies: has-flag: 4.0.0 - supports-hyperlinks@4.4.0: + supports-hyperlinks@4.5.0: dependencies: has-flag: 5.0.1 supports-color: 10.2.2 From 9448417157a9e690d87213c89ea9913803373b4f Mon Sep 17 00:00:00 2001 From: august Date: Fri, 3 Jul 2026 03:48:15 -0700 Subject: [PATCH 047/197] project_symbols: Add preview to project symbols picker (#59863) Candidate fix for #59822. Adds a live preview pane to the project symbols picker, matching the file finder and project search pickers. When the selection moves, the selected symbol's file is shown in the preview with its declaration line highlighted and vertically centered. ## Changes - `project_symbols`: construct the picker with `uniform_list_with_preview`; asynchronously open the buffer for the selected symbol (cached by candidate id, cleared on each new query) and implement `try_get_preview_data_for_match` to return the buffer plus the symbol's anchor range. - `picker`: add a public `refresh_preview` so a delegate can push preview data once a buffer finishes opening asynchronously (the symbol buffer is not available synchronously, unlike text search which already holds open buffers). #### Before image ##### After image Can turn preview pane off by clicking at the bottom button! ## AI disclosure AI was used for understanding the codebase and formatting this PR. Release Notes: - Added a preview pane to the project symbols picker --------- Co-authored-by: Yara --- Cargo.lock | 2 +- crates/picker/src/preview.rs | 19 +++++++ crates/picker_preview/Cargo.toml | 2 +- crates/picker_preview/src/picker_preview.rs | 51 +++++++++++++++---- crates/project_symbols/Cargo.toml | 1 + crates/project_symbols/src/project_symbols.rs | 13 +++-- 6 files changed, 74 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e2fcdbf54f8..432d250584e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13479,7 +13479,6 @@ dependencies = [ name = "picker_preview" version = "0.1.0" dependencies = [ - "anyhow", "editor", "gpui", "language", @@ -14129,6 +14128,7 @@ dependencies = [ "lsp", "ordered-float 2.10.1", "picker", + "picker_preview", "project", "release_channel", "semver", diff --git a/crates/picker/src/preview.rs b/crates/picker/src/preview.rs index 88a9b6219db..df3e9604f70 100644 --- a/crates/picker/src/preview.rs +++ b/crates/picker/src/preview.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use gpui::{AnyElement, App, Entity, IntoElement, Window}; use language::{Anchor, Buffer, HighlightedText}; +use project::Symbol; /// The editor-agnostic interface a [`Picker`](crate::Picker) uses to drive its /// preview. @@ -67,6 +68,13 @@ pub enum PreviewSource { /// Used by pickers (like the text picker) that already hold the matched /// buffer. Buffer(Entity), + /// The buffer is identified by a project symbol; the preview opens it and + /// highlights the symbol's range. + /// + /// Used by pickers (like the project symbols picker) that only know the + /// matched symbol. The highlight is derived from the symbol once its buffer + /// loads, so callers don't supply a [`MatchLocation`]. + Symbol(Symbol), /// No buffer to show; display this message centered in the preview instead. /// /// Used by pickers that have a selection without a previewable buffer (like @@ -117,4 +125,15 @@ impl Update { match_location: Some(highlight), } } + + /// Preview the buffer for `symbol`, highlighting and scrolling to its range. + /// + /// The buffer is opened and the highlight derived by the preview once the + /// buffer loads. + pub fn from_symbol(symbol: Symbol) -> Self { + Self { + source: PreviewSource::Symbol(symbol), + match_location: None, + } + } } diff --git a/crates/picker_preview/Cargo.toml b/crates/picker_preview/Cargo.toml index 49417be5435..12d7aec9cc3 100644 --- a/crates/picker_preview/Cargo.toml +++ b/crates/picker_preview/Cargo.toml @@ -13,7 +13,7 @@ path = "src/picker_preview.rs" doctest = false [dependencies] -anyhow.workspace = true + editor.workspace = true gpui.workspace = true language.workspace = true diff --git a/crates/picker_preview/src/picker_preview.rs b/crates/picker_preview/src/picker_preview.rs index 50aff9d3ce7..eb4e179171c 100644 --- a/crates/picker_preview/src/picker_preview.rs +++ b/crates/picker_preview/src/picker_preview.rs @@ -4,16 +4,17 @@ use std::sync::Arc; use gpui::{ Action, AnyElement, App, AppContext as _, Context, Entity, IntoElement, Pixels, StyledText, - Task, TaskExt as _, Window, px, + Task, Window, px, }; -use language::{Buffer, HighlightedText, HighlightedTextBuilder, ToPoint}; +use language::{Bias, Buffer, HighlightedText, HighlightedTextBuilder, ToPoint}; use picker::{ MatchLocation, PreviewBackend, PreviewLayout, PreviewSource, PreviewUpdate, ToMultiBuffer, }; -use project::Project; +use project::{Project, Symbol}; use rope::Point; use settings::Settings; use ui::{ActiveTheme, Color, div, prelude::*, v_flex}; +use util::ResultExt as _; use util::rel_path::RelPath; use editor::{Editor, EditorSettings, RowHighlightOptions, display_map::HighlightKey}; @@ -61,6 +62,8 @@ struct EditorPreview { /// When set show a text message instead of a preview message: Option, preview_editor: Entity, + /// Store the load preview task so we have only one at the time + pending_update: Task<()>, } impl EditorPreview { @@ -100,6 +103,7 @@ impl EditorPreview { preview_editor, current_path: None, message: None, + pending_update: Task::ready(()), }; this.clear(); // picker starts with no results. this @@ -125,6 +129,9 @@ impl EditorPreview { self.update_from_buffer(buffer, highlight, window, cx); cx.notify(); } + PreviewSource::Symbol(symbol) => { + self.update_from_symbol(symbol, window, cx); + } PreviewSource::Message(message) => { self.message = Some(message); cx.notify(); @@ -152,15 +159,41 @@ impl EditorPreview { } }); - cx.spawn_in(window, async move |this, cx| { - let buffer = open_task.await?; + self.pending_update = cx.spawn_in(window, async move |this, cx| { + let Some(buffer) = open_task.await.log_err() else { + return; + }; this.update_in(cx, |this, window, cx| { this.update_from_buffer(buffer, highlight, window, cx); cx.notify(); - })?; - anyhow::Ok(()) - }) - .detach_and_log_err(cx); + }) + .ok(); + }); + } + + fn update_from_symbol(&mut self, symbol: Symbol, window: &mut Window, cx: &mut Context) { + let open_task = self.project.update(cx, |project, cx| { + project.open_buffer_for_symbol(&symbol, cx) + }); + + self.pending_update = cx.spawn_in(window, async move |this, cx| { + let Some(buffer) = open_task.await.log_err() else { + return; + }; + this.update_in(cx, |this, window, cx| { + let snapshot = buffer.read(cx).text_snapshot(); + let start = snapshot.clip_point_utf16(symbol.range.start, Bias::Left); + let end = snapshot.clip_point_utf16(symbol.range.end, Bias::Left); + let highlight = MatchLocation { + anchor_range: snapshot.anchor_before(start)..snapshot.anchor_after(end), + range: snapshot.point_utf16_to_offset(start) + ..snapshot.point_utf16_to_offset(end), + }; + this.update_from_buffer(buffer, Some(highlight), window, cx); + cx.notify(); + }) + .ok(); + }); } fn update_from_buffer( diff --git a/crates/project_symbols/Cargo.toml b/crates/project_symbols/Cargo.toml index da23116e83b..62074471e14 100644 --- a/crates/project_symbols/Cargo.toml +++ b/crates/project_symbols/Cargo.toml @@ -19,6 +19,7 @@ fuzzy.workspace = true gpui.workspace = true ordered-float.workspace = true picker.workspace = true +picker_preview.workspace = true project.workspace = true serde_json.workspace = true settings.workspace = true diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index 5e4422a6fb7..533913a654a 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -5,7 +5,7 @@ use gpui::{ TextStyle, WeakEntity, Window, relative, }; use ordered_float::OrderedFloat; -use picker::{Picker, PickerDelegate}; +use picker::{Picker, PickerDelegate, PreviewUpdate}; use project::{Project, Symbol, lsp_store::SymbolLocation}; use settings::Settings; use std::{cmp::Reverse, sync::Arc}; @@ -25,8 +25,9 @@ pub fn init(cx: &mut App) { let project = workspace.project().clone(); let handle = cx.entity().downgrade(); workspace.toggle_modal(window, cx, move |window, cx| { - let delegate = ProjectSymbolsDelegate::new(handle, project); - Picker::uniform_list(delegate, window, cx) + let delegate = ProjectSymbolsDelegate::new(handle, project.clone()); + let preview = picker_preview::editor_preview(project, window, cx); + Picker::uniform_list_with_preview(delegate, preview, window, cx) }) }, ); @@ -187,6 +188,12 @@ impl PickerDelegate for ProjectSymbolsDelegate { self.selected_match_index = ix; } + fn try_get_preview_data_for_match(&self, _cx: &App) -> Option { + let candidate_id = self.matches.get(self.selected_match_index)?.candidate_id; + let symbol = self.symbols.get(candidate_id)?.clone(); + Some(PreviewUpdate::from_symbol(symbol)) + } + fn update_matches( &mut self, query: String, From 7ed553b391393ee4e62782e710605778e2200a18 Mon Sep 17 00:00:00 2001 From: Maja Backman Date: Fri, 3 Jul 2026 07:41:48 -0400 Subject: [PATCH 048/197] acp_thread: Shrink ACP terminal scrollback to used on exit (#60019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective ACP agents (Cursor, Claude Code, Codex, etc.) start a display-only `terminal::Terminal` for each bash tool call. After the call exits, the terminal stays alive so that its output stays visible. But the `alacritty` `Grid` keeps a `Storage` cache that's never reclaimed. Across a long session this memory adds up. Partially fixes #57099 ## Solution Added `Terminal::shrink_to_used`, called from the `Exit` branch of `on_terminal_provider_event`. This calls `Grid::truncate()` on the alacritty grid, shrinking the `Storage` cache portion while leaving user-visible scrollback. ## Testing - `cargo nextest run -p terminal shrink_to_used_preserves_user_visible_scrollback` passes — exercises the path with >10K rows of scrollback. - `./script/clippy` clean. - Manually verified: ran a noisy bash tool call via an ACP agent, observed exit, then scrolled back through the output, no regression. ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Improved memory usage of ACP terminals after the tool call exits --------- Co-authored-by: Bennet Bo Fenner --- crates/acp_thread/src/acp_thread.rs | 83 ++++++++++++++++++++++++++++- crates/terminal/src/alacritty.rs | 4 ++ crates/terminal/src/terminal.rs | 10 ++-- 3 files changed, 92 insertions(+), 5 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index bf5b97607bb..2bbda6647af 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -4637,7 +4637,8 @@ impl AcpThread { } if let Some(_status) = self.pending_terminal_exit.remove(&terminal_id) { - entity.update(cx, |_term, cx| { + entity.update(cx, |term, cx| { + term.inner().update(cx, |inner, _| inner.shrink_to_used()); cx.notify(); }); } @@ -4673,7 +4674,8 @@ impl AcpThread { status, } => { if let Some(entity) = self.terminals.get(&terminal_id) { - entity.update(cx, |_term, cx| { + entity.update(cx, |term, cx| { + term.inner().update(cx, |inner, _| inner.shrink_to_used()); cx.notify(); }); } else { @@ -5102,6 +5104,83 @@ mod tests { ); } + #[gpui::test] + async fn test_terminal_exit_preserves_visible_scrollback(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session( + project, + PathList::new(&[std::path::Path::new(path!("/test"))]), + cx, + ) + }) + .await + .unwrap(); + + let terminal_id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); + let lower = cx.new(|cx| { + let builder = ::terminal::TerminalBuilder::new_display_only( + ::terminal::terminal_settings::CursorShape::default(), + ::terminal::terminal_settings::AlternateScroll::On, + None, + 0, + cx.background_executor(), + PathStyle::local(), + ); + builder.subscribe(cx) + }); + + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Created { + terminal_id: terminal_id.clone(), + label: "Buffered Test".to_string(), + cwd: None, + output_byte_limit: None, + terminal: lower.clone(), + }, + cx, + ); + }); + + let mut output = String::new(); + for line in 0..15_000 { + output.push_str(&format!("line {line}\n")); + } + + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Output { + terminal_id: terminal_id.clone(), + data: output.into_bytes(), + }, + cx, + ); + thread.on_terminal_provider_event( + TerminalProviderEvent::Exit { + terminal_id: terminal_id.clone(), + status: acp::TerminalExitStatus::new().exit_code(0), + }, + cx, + ); + }); + + let content = thread.read_with(cx, |thread, cx| { + let term = thread.terminal(terminal_id.clone()).unwrap(); + term.read_with(cx, |term, cx| term.inner().read(cx).get_content()) + }); + + assert!( + content.contains("line 14999"), + "expected output to remain visible after terminal exit, got: {content}" + ); + } + #[gpui::test] async fn test_terminal_output_and_exit_buffered_before_created(cx: &mut gpui::TestAppContext) { init_test(cx); diff --git a/crates/terminal/src/alacritty.rs b/crates/terminal/src/alacritty.rs index 99b6eadf2fd..5766d8b8fde 100644 --- a/crates/terminal/src/alacritty.rs +++ b/crates/terminal/src/alacritty.rs @@ -800,6 +800,10 @@ pub(super) fn clear_saved_screen(term: &mut Term) { } } +pub(super) fn shrink_to_used(term: &mut Term) { + term.grid_mut().truncate(); +} + pub(super) fn make_content(term: &Term, last_content: &Content) -> Content { let content = term.renderable_content(); diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index b3b6c7aff81..9fbfd575ded 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -67,9 +67,9 @@ use crate::alacritty::{ display_only_term_config, find_from_terminal_point, full_content_range, last_non_empty_lines, make_content, new_term, open_pty, pty_options, pty_term_config, resize, screen_lines, scroll_display, scroll_to_point, search_matches, selection_text, set_default_cursor_style, - set_selection as set_term_selection, spawn_event_loop, toggle_vi_mode as toggle_term_vi_mode, - total_lines, update_selection as update_term_selection, update_selection_to_vi_cursor, - update_vi_cursor_for_scroll, vi_goto_point, vi_motion, + set_selection as set_term_selection, shrink_to_used, spawn_event_loop, + toggle_vi_mode as toggle_term_vi_mode, total_lines, update_selection as update_term_selection, + update_selection_to_vi_cursor, update_vi_cursor_for_scroll, vi_goto_point, vi_motion, }; use crate::mappings::colors::to_vte_rgb; use crate::mappings::keys::to_esc_str; @@ -1888,6 +1888,10 @@ impl Terminal { self.events.push_back(InternalEvent::Clear) } + pub fn shrink_to_used(&mut self) { + shrink_to_used(&mut self.term.lock()); + } + pub fn scroll_line_up(&mut self) { self.events .push_back(InternalEvent::Scroll(Scroll::Delta(1))); From be7e5b0338dbb49170913ba539c4e743d9d071f4 Mon Sep 17 00:00:00 2001 From: di404 Date: Fri, 3 Jul 2026 21:48:03 +0800 Subject: [PATCH 049/197] terminal_view: Show terminal inline assist keybinding in tooltip (#55903) ## Summary - Fixed the terminal inline assist tab bar tooltip so it resolves the keybinding from the active terminal view. - The bug happened when the initial terminal was closed and a new terminal was opened: the tab bar button kept a cached focus handle for the old terminal, so tooltip keybinding lookup could no longer find the terminal key context. - The button is now created while rendering the tab bar with the current terminal view's focus handle, avoiding stale focus handles as terminals are closed and recreated. ## Validation - `cargo fmt --package terminal_view --check` - `cargo check -p terminal_view --message-format short` Release Notes: - Fixed the terminal inline assist toolbar tooltip not showing its keybinding after reopening terminals. --------- Co-authored-by: Smit Barmase --- crates/terminal_view/src/terminal_panel.rs | 89 +++++++++++++++------- 1 file changed, 61 insertions(+), 28 deletions(-) diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 25e4ad9d999..e1f31c3614c 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -11,9 +11,9 @@ use collections::HashMap; use db::kvp::KeyValueStore; use futures::{channel::oneshot, future::join_all}; use gpui::{ - Action, Anchor, AnyView, App, AsyncApp, AsyncWindowContext, Context, Entity, EventEmitter, - FocusHandle, Focusable, IntoElement, ParentElement, Pixels, Render, Styled, Task, TaskExt, - WeakEntity, Window, actions, + Action, Anchor, App, AsyncApp, AsyncWindowContext, Context, Entity, EventEmitter, FocusHandle, + Focusable, IntoElement, ParentElement, Pixels, Render, Styled, Task, TaskExt, WeakEntity, + Window, actions, }; use itertools::Itertools; use project::{Fs, Project}; @@ -83,7 +83,6 @@ pub struct TerminalPanel { pending_terminals_to_add: usize, deferred_tasks: HashMap>, assistant_enabled: bool, - assistant_tab_bar_button: Option, active: bool, } @@ -101,7 +100,6 @@ impl TerminalPanel { pending_terminals_to_add: 0, deferred_tasks: HashMap::default(), assistant_enabled: false, - assistant_tab_bar_button: None, active: false, }; terminal_panel.apply_tab_bar_buttons(&terminal_panel.active_pane, cx); @@ -110,20 +108,6 @@ impl TerminalPanel { pub fn set_assistant_enabled(&mut self, enabled: bool, cx: &mut Context) { self.assistant_enabled = enabled; - if enabled { - let focus_handle = self - .active_pane - .read(cx) - .active_item() - .map(|item| item.item_focus_handle(cx)) - .unwrap_or(self.focus_handle(cx)); - self.assistant_tab_bar_button = Some( - cx.new(move |_| InlineAssistTabBarButton { focus_handle }) - .into(), - ); - } else { - self.assistant_tab_bar_button = None; - } for pane in self.center.panes() { self.apply_tab_bar_buttons(pane, cx); } @@ -134,7 +118,7 @@ impl TerminalPanel { terminal_pane: &Entity, cx: &mut Context, ) { - let assistant_tab_bar_button = self.assistant_tab_bar_button.clone(); + let assistant_enabled = self.assistant_enabled; terminal_pane.update(cx, |pane, cx| { pane.set_render_tab_bar_buttons(cx, move |pane, window, cx| { let split_context = pane @@ -182,7 +166,11 @@ impl TerminalPanel { Some(menu) }), ) - .children(assistant_tab_bar_button.clone()) + .when(assistant_enabled, |this| { + this.when_some(split_context.clone(), |this, focus_handle| { + this.child(InlineAssistTabBarButton { focus_handle }) + }) + }) .child( PopoverMenu::new("terminal-pane-tab-bar-split") .trigger_with_tooltip( @@ -1705,18 +1693,22 @@ impl workspace::TerminalProvider for TerminalProvider { } } +#[derive(IntoElement)] struct InlineAssistTabBarButton { focus_handle: FocusHandle, } -impl Render for InlineAssistTabBarButton { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let focus_handle = self.focus_handle.clone(); +impl RenderOnce for InlineAssistTabBarButton { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let focus_handle = self.focus_handle; IconButton::new("terminal_inline_assistant", IconName::ZedAssistant) .icon_size(IconSize::Small) - .on_click(cx.listener(|_, _, window, cx| { - window.dispatch_action(InlineAssist::default().boxed_clone(), cx); - })) + .on_click({ + let focus_handle = focus_handle.clone(); + move |_, window, cx| { + focus_handle.dispatch_action(&InlineAssist::default(), window, cx); + } + }) .tooltip(move |_window, cx| { Tooltip::for_action_in("Inline Assist", &InlineAssist::default(), &focus_handle, cx) }) @@ -1728,7 +1720,7 @@ mod tests { use std::num::NonZero; use super::*; - use gpui::{TestAppContext, UpdateGlobal as _}; + use gpui::{Modifiers, TestAppContext, UpdateGlobal as _, VisualTestContext}; use pretty_assertions::assert_eq; use project::FakeFs; use settings::SettingsStore; @@ -1920,6 +1912,47 @@ mod tests { ); } + #[gpui::test] + async fn test_inline_assist_tooltip_shows_keybinding_of_active_terminal( + cx: &mut TestAppContext, + ) { + cx.executor().allow_parking(); + init_test(cx); + + cx.update(|cx| { + cx.bind_keys([gpui::KeyBinding::new( + "ctrl-enter", + InlineAssist::default(), + Some("Terminal"), + )]) + }); + + let (window_handle, terminal_panel) = init_workspace_with_panel(cx).await; + let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); + + terminal_panel.update(cx, |panel, cx| panel.set_assistant_enabled(true, cx)); + terminal_panel + .update_in(cx, |panel, window, cx| { + panel.add_terminal_shell(None, RevealStrategy::Always, window, cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let button_bounds = cx + .debug_bounds("ICON-ZedAssistant") + .expect("inline assist button should be rendered in the terminal tab bar"); + cx.simulate_mouse_move(button_bounds.center(), None, Modifiers::default()); + + cx.executor().advance_clock(Duration::from_millis(600)); + cx.run_until_parked(); + + assert!( + cx.debug_bounds("KEY_BINDING-enter").is_some(), + "tooltip should show the InlineAssist keybinding resolved in the terminal's context" + ); + } + async fn init_workspace_with_panel( cx: &mut TestAppContext, ) -> (gpui::WindowHandle, Entity) { From 4da73e19700e2ea8bff391243e43777e807527ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Craig?= Date: Fri, 3 Jul 2026 11:56:37 -0300 Subject: [PATCH 050/197] git_ui: Add configure and docs links to commit message tooltip (#60357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When no LLM provider is configured, hovering the disabled "Generate Commit Message" button in the git panel/commit modal previously showed a plain, non-interactive tooltip with no way to act on it. This tooltip now includes two links: - **Configure Provider** — jumps directly to the "LLM Providers" settings sub-page. - **See Docs** — opens `https://zed.dev/docs/ai/llm-providers`. Also adds `IconButton::hoverable_tooltip`, mirroring the existing `.tooltip(...)` forwarding, since `IconButton` didn't previously expose the underlying `ButtonLike::hoverable_tooltip` capability needed for interactive tooltip content. Release Notes: - Improved the commit message tooltip to link directly to LLM provider settings and documentation when no provider is configured. --------- Co-authored-by: Danilo Leal --- Cargo.lock | 1 + crates/client/src/zed_urls.rs | 4 + crates/git_ui/Cargo.toml | 9 +- crates/git_ui/src/git_panel.rs | 106 +++++++++++++----- .../ui/src/components/button/icon_button.rs | 11 ++ 5 files changed, 100 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 432d250584e..480b91504c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7368,6 +7368,7 @@ dependencies = [ "async-channel 2.5.0", "buffer_diff", "call", + "client", "collections", "component", "ctor", diff --git a/crates/client/src/zed_urls.rs b/crates/client/src/zed_urls.rs index 0473ff3932a..6eb69b83273 100644 --- a/crates/client/src/zed_urls.rs +++ b/crates/client/src/zed_urls.rs @@ -69,6 +69,10 @@ pub fn skills_docs(cx: &App) -> String { format!("{docs_url}/ai/skills", docs_url = docs_url(cx)) } +pub fn llm_provider_docs(cx: &App) -> String { + format!("{docs_url}/ai/llm-providers", docs_url = docs_url(cx)) +} + /// Returns the URL to Zed's ACP registry blog post. pub fn acp_registry_blog(cx: &App) -> String { format!( diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 4b6fc85041b..f46146ef8c3 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -23,14 +23,15 @@ askpass.workspace = true async-channel.workspace = true buffer_diff.workspace = true call = { workspace = true, optional = true } +client.workspace = true collections.workspace = true component.workspace = true db.workspace = true editor.workspace = true file_icons.workspace = true fs.workspace = true -futures.workspace = true futures-lite.workspace = true +futures.workspace = true fuzzy.workspace = true fuzzy_nucleo.workspace = true git.workspace = true @@ -50,8 +51,8 @@ prompt_store.workspace = true proto.workspace = true rand.workspace = true release_channel.workspace = true -remote_connection.workspace = true remote.workspace = true +remote_connection.workspace = true schemars.workspace = true search.workspace = true serde.workspace = true @@ -67,6 +68,7 @@ theme.workspace = true theme_settings.workspace = true time.workspace = true time_format.workspace = true +tracing.workspace = true ui.workspace = true ui_input.workspace = true util.workspace = true @@ -75,7 +77,6 @@ workspace.workspace = true zed_actions.workspace = true zeroize.workspace = true ztracing.workspace = true -tracing.workspace = true [target.'cfg(windows)'.dependencies] windows.workspace = true @@ -91,12 +92,12 @@ indoc.workspace = true pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } rand.workspace = true +remote_connection = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } task.workspace = true unindent.workspace = true workspace = { workspace = true, features = ["test-support"] } zlog.workspace = true -remote_connection = { workspace = true, features = ["test-support"] } [package.metadata.cargo-machete] ignored = ["tracing"] diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 0ace54e100e..445b991a132 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -13,6 +13,7 @@ use crate::{ use agent_settings::{AgentSettings, UserAgentsMd}; use anyhow::Context as _; use askpass::AskPassDelegate; +use client::zed_urls; use collections::{BTreeMap, HashMap, HashSet}; use db::kvp::KeyValueStore; use editor::{Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset, SizingBehavior}; @@ -4774,34 +4775,38 @@ impl GitPanel { let editor_focus_handle = self.commit_editor.focus_handle(cx); - Some( - IconButton::new("generate-commit-message", IconName::AiEdit) - .shape(ui::IconButtonShape::Square) - .icon_color(if has_commit_model_configuration_error { - Color::Disabled + let button = IconButton::new("generate-commit-message", IconName::AiEdit) + .shape(ui::IconButtonShape::Square) + .icon_color(if has_commit_model_configuration_error { + Color::Disabled + } else { + Color::Muted + }) + .disabled(!can_commit || has_commit_model_configuration_error) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.generate_commit_message(cx); + })); + + let button = if can_commit && has_commit_model_configuration_error { + button.hoverable_tooltip(move |_window, cx| { + cx.new(|_| GenerateCommitMessageConfigurationTooltip).into() + }) + } else { + button.tooltip(move |_window, cx| { + if !can_commit { + Tooltip::simple("No Changes to Commit", cx) } else { - Color::Muted - }) - .tooltip(move |_window, cx| { - if !can_commit { - Tooltip::simple("No Changes to Commit", cx) - } else if has_commit_model_configuration_error { - Tooltip::simple("Configure an LLM provider to generate commit messages", cx) - } else { - Tooltip::for_action_in( - "Generate Commit Message", - &git::GenerateCommitMessage, - &editor_focus_handle, - cx, - ) - } - }) - .disabled(!can_commit || has_commit_model_configuration_error) - .on_click(cx.listener(move |this, _event, _window, cx| { - this.generate_commit_message(cx); - })) - .into_any_element(), - ) + Tooltip::for_action_in( + "Generate Commit Message", + &git::GenerateCommitMessage, + &editor_focus_handle, + cx, + ) + } + }) + }; + + Some(button.into_any_element()) } pub(crate) fn render_co_authors(&self, cx: &Context) -> Option { @@ -7157,6 +7162,53 @@ impl GitPanel { } } +struct GenerateCommitMessageConfigurationTooltip; + +impl Render for GenerateCommitMessageConfigurationTooltip { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + ui::tooltip_container(cx, |container, _cx| { + container + .gap_1p5() + .child(Label::new( + "Configure an LLM provider to generate commit messages.", + )) + .child( + h_flex() + .gap_1() + .child( + Button::new("configure-commit-message-provider", "Configure Provider") + .style(ButtonStyle::Filled) + .layer(ElevationIndex::ModalSurface) + .label_size(LabelSize::Small) + .on_click(|_, window, cx| { + window.dispatch_action( + zed_actions::OpenSettingsAt { + path: "llm_providers".to_string(), + target: None, + } + .boxed_clone(), + cx, + ); + }), + ) + .child( + Button::new("llm-provider-docs", "See Docs") + .style(ButtonStyle::OutlinedGhost) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .color(Color::Muted) + .size(IconSize::Small), + ) + .label_size(LabelSize::Small) + .on_click(move |_, _, cx| { + cx.open_url(&zed_urls::llm_provider_docs(cx)) + }), + ), + ) + }) + } +} + impl GitPanel { pub fn selected_file_history_target(&self) -> Option<(Entity, RepoPath)> { let entry = self.get_selected_entry()?.status_entry()?; diff --git a/crates/ui/src/components/button/icon_button.rs b/crates/ui/src/components/button/icon_button.rs index a05a538b4a7..2da97d4bc56 100644 --- a/crates/ui/src/components/button/icon_button.rs +++ b/crates/ui/src/components/button/icon_button.rs @@ -124,6 +124,17 @@ impl IconButton { self } + + /// Use the given callback to construct a new tooltip view when the mouse hovers over this + /// button. The tooltip itself is also hoverable and won't disappear when the user moves the + /// mouse into the tooltip, allowing it to contain interactive elements like links or buttons. + pub fn hoverable_tooltip( + mut self, + tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static, + ) -> Self { + self.base = self.base.hoverable_tooltip(tooltip); + self + } } impl Disableable for IconButton { From b497e2024aebe0adeefde670f6f3e1ef9c4257a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20H=C3=B8iby?= Date: Fri, 3 Jul 2026 17:10:39 +0200 Subject: [PATCH 051/197] Add Claude Fable 5 to Amazon Bedrock (#59016) 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable What: image Tested towards EU region off AWS. Makes Claude Fabel 5 accessible through bedrock. To enable Fable you need to opt in to `provider_data_share` in AWS. [AWS Docs](https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-fable-5.html) Release Notes: - Added Claude Fable 5 to the Amazon Bedrock provider. --------- Co-authored-by: Neel --- crates/bedrock/src/models.rs | 72 ++++++++++++++++--- .../language_models/src/provider/bedrock.rs | 12 ++++ 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/crates/bedrock/src/models.rs b/crates/bedrock/src/models.rs index a2f299a3c9e..9ebeffda7a8 100644 --- a/crates/bedrock/src/models.rs +++ b/crates/bedrock/src/models.rs @@ -107,6 +107,13 @@ pub enum Model { alias = "claude-sonnet-4-6-thinking-latest" )] ClaudeSonnet4_6, + #[serde( + rename = "claude-fable-5", + alias = "claude-fable-5-latest", + alias = "claude-fable-5-thinking", + alias = "claude-fable-5-thinking-latest" + )] + ClaudeFable5, // Meta Llama 4 models #[serde(rename = "llama-4-scout-17b")] @@ -219,7 +226,9 @@ impl Model { } pub fn from_id(id: &str) -> anyhow::Result { - if id.starts_with("claude-opus-4-8") { + if id.starts_with("claude-fable-5") { + Ok(Self::ClaudeFable5) + } else if id.starts_with("claude-opus-4-8") { Ok(Self::ClaudeOpus4_8) } else if id.starts_with("claude-opus-4-7") { Ok(Self::ClaudeOpus4_7) @@ -253,6 +262,7 @@ impl Model { Self::ClaudeOpus4_7 => "claude-opus-4-7", Self::ClaudeOpus4_8 => "claude-opus-4-8", Self::ClaudeSonnet4_6 => "claude-sonnet-4-6", + Self::ClaudeFable5 => "claude-fable-5", Self::Llama4Scout17B => "llama-4-scout-17b", Self::Llama4Maverick17B => "llama-4-maverick-17b", Self::Gemma3_4B => "gemma-3-4b", @@ -304,6 +314,7 @@ impl Model { Self::ClaudeOpus4_7 => "anthropic.claude-opus-4-7", Self::ClaudeOpus4_8 => "anthropic.claude-opus-4-8", Self::ClaudeSonnet4_6 => "anthropic.claude-sonnet-4-6", + Self::ClaudeFable5 => "anthropic.claude-fable-5", Self::Llama4Scout17B => "meta.llama4-scout-17b-instruct-v1:0", Self::Llama4Maverick17B => "meta.llama4-maverick-17b-instruct-v1:0", Self::Gemma3_4B => "google.gemma-3-4b-it", @@ -355,6 +366,7 @@ impl Model { Self::ClaudeOpus4_7 => "Claude Opus 4.7", Self::ClaudeOpus4_8 => "Claude Opus 4.8", Self::ClaudeSonnet4_6 => "Claude Sonnet 4.6", + Self::ClaudeFable5 => "Claude Fable 5", Self::Llama4Scout17B => "Llama 4 Scout 17B", Self::Llama4Maverick17B => "Llama 4 Maverick 17B", Self::Gemma3_4B => "Gemma 3 4B", @@ -406,7 +418,8 @@ impl Model { | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_8 - | Self::ClaudeSonnet4_6 => 1_000_000, + | Self::ClaudeSonnet4_6 + | Self::ClaudeFable5 => 1_000_000, Self::ClaudeOpus4_1 => 200_000, Self::Llama4Scout17B | Self::Llama4Maverick17B => 128_000, Self::Gemma3_4B | Self::Gemma3_12B | Self::Gemma3_27B => 128_000, @@ -440,7 +453,10 @@ impl Model { | Self::ClaudeOpus4_5 | Self::ClaudeSonnet4_6 => 64_000, Self::ClaudeOpus4_1 => 32_000, - Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_8 => 128_000, + Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_8 + | Self::ClaudeFable5 => 128_000, Self::Llama4Scout17B | Self::Llama4Maverick17B | Self::Gemma3_4B @@ -480,7 +496,8 @@ impl Model { | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_8 - | Self::ClaudeSonnet4_6 => 1.0, + | Self::ClaudeSonnet4_6 + | Self::ClaudeFable5 => 1.0, Self::Custom { default_temperature, .. @@ -499,7 +516,8 @@ impl Model { | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_8 - | Self::ClaudeSonnet4_6 => true, + | Self::ClaudeSonnet4_6 + | Self::ClaudeFable5 => true, Self::NovaLite | Self::NovaPro | Self::NovaPremier | Self::Nova2Lite => true, Self::MistralLarge3 | Self::PixtralLarge | Self::MagistralSmall => true, Self::Devstral2_123B | Self::Ministral14B => true, @@ -531,7 +549,8 @@ impl Model { | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_8 - | Self::ClaudeSonnet4_6 => true, + | Self::ClaudeSonnet4_6 + | Self::ClaudeFable5 => true, Self::NovaLite | Self::NovaPro => true, Self::PixtralLarge => true, Self::Qwen3VL235B => true, @@ -550,7 +569,8 @@ impl Model { | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_8 - | Self::ClaudeSonnet4_6 => true, + | Self::ClaudeSonnet4_6 + | Self::ClaudeFable5 => true, Self::Custom { cache_configuration, .. @@ -571,18 +591,23 @@ impl Model { | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_8 | Self::ClaudeSonnet4_6 + | Self::ClaudeFable5 ) } pub fn supports_adaptive_thinking(&self) -> bool { matches!( self, - Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_8 | Self::ClaudeSonnet4_6 + Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_8 + | Self::ClaudeSonnet4_6 + | Self::ClaudeFable5 ) } pub fn supports_xhigh_adaptive_thinking(&self) -> bool { - matches!(self, Self::ClaudeOpus4_8) + matches!(self, Self::ClaudeOpus4_8 | Self::ClaudeFable5) } pub fn thinking_mode(&self) -> BedrockModelMode { @@ -616,6 +641,7 @@ impl Model { | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_8 | Self::ClaudeSonnet4_6 + | Self::ClaudeFable5 | Self::Nova2Lite ); @@ -677,6 +703,7 @@ impl Model { | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_8 | Self::ClaudeSonnet4_6 + | Self::ClaudeFable5 | Self::Nova2Lite, "global", ) => Ok(format!("{}.{}", region_group, model_id)), @@ -695,6 +722,7 @@ impl Model { | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_8 | Self::ClaudeSonnet4_6 + | Self::ClaudeFable5 | Self::Llama4Scout17B | Self::Llama4Maverick17B | Self::NovaLite @@ -718,6 +746,7 @@ impl Model { | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_8 | Self::ClaudeSonnet4_6 + | Self::ClaudeFable5 | Self::NovaLite | Self::NovaPro | Self::Nova2Lite, @@ -775,6 +804,10 @@ mod tests { Model::ClaudeSonnet4.cross_region_inference_id("us-west-2", false)?, "us.anthropic.claude-sonnet-4-20250514-v1:0" ); + assert_eq!( + Model::ClaudeFable5.cross_region_inference_id("us-east-1", false)?, + "us.anthropic.claude-fable-5" + ); assert_eq!( Model::NovaPro.cross_region_inference_id("us-east-2", false)?, "us.amazon.nova-pro-v1:0" @@ -812,6 +845,10 @@ mod tests { Model::ClaudeOpus4_8.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-opus-4-8" ); + assert_eq!( + Model::ClaudeFable5.cross_region_inference_id("eu-west-1", false)?, + "eu.anthropic.claude-fable-5" + ); Ok(()) } @@ -918,6 +955,10 @@ mod tests { Model::ClaudeOpus4_8.cross_region_inference_id("us-east-1", true)?, "global.anthropic.claude-opus-4-8" ); + assert_eq!( + Model::ClaudeFable5.cross_region_inference_id("us-east-1", true)?, + "global.anthropic.claude-fable-5" + ); assert_eq!( Model::Nova2Lite.cross_region_inference_id("us-east-1", true)?, "global.amazon.nova-2-lite-v1:0" @@ -989,6 +1030,7 @@ mod tests { assert_eq!(Model::NovaLite.id(), "nova-lite"); assert_eq!(Model::DeepSeekR1.id(), "deepseek-r1"); assert_eq!(Model::Llama4Scout17B.id(), "llama-4-scout-17b"); + assert_eq!(Model::ClaudeFable5.id(), "claude-fable-5"); assert_eq!( Model::ClaudeSonnet4_5.request_id(), @@ -1000,6 +1042,7 @@ mod tests { Model::Llama4Scout17B.request_id(), "meta.llama4-scout-17b-instruct-v1:0" ); + assert_eq!(Model::ClaudeFable5.request_id(), "anthropic.claude-fable-5"); // Thinking aliases deserialize to the same model assert_eq!(Model::ClaudeSonnet4.id(), "claude-sonnet-4"); @@ -1007,6 +1050,10 @@ mod tests { Model::from_id("claude-sonnet-4-thinking").unwrap().id(), "claude-sonnet-4" ); + assert_eq!( + Model::from_id("claude-fable-5-thinking").unwrap().id(), + "claude-fable-5" + ); } #[test] @@ -1015,11 +1062,14 @@ mod tests { assert!(Model::ClaudeSonnet4.supports_thinking()); assert!(Model::ClaudeSonnet4_5.supports_thinking()); assert!(Model::ClaudeOpus4_6.supports_thinking()); + assert!(Model::ClaudeFable5.supports_thinking()); assert!(!Model::ClaudeSonnet4.supports_adaptive_thinking()); assert!(Model::ClaudeOpus4_6.supports_adaptive_thinking()); assert!(Model::ClaudeSonnet4_6.supports_adaptive_thinking()); + assert!(Model::ClaudeFable5.supports_adaptive_thinking()); assert!(!Model::ClaudeOpus4_7.supports_xhigh_adaptive_thinking()); + assert!(Model::ClaudeFable5.supports_xhigh_adaptive_thinking()); assert!(Model::ClaudeOpus4_8.supports_xhigh_adaptive_thinking()); assert_eq!(BedrockAdaptiveThinkingEffort::XHigh.as_str(), "xhigh"); @@ -1047,6 +1097,7 @@ mod tests { fn test_max_token_count() { assert_eq!(Model::ClaudeSonnet4_5.max_token_count(), 1_000_000); assert_eq!(Model::ClaudeOpus4_6.max_token_count(), 1_000_000); + assert_eq!(Model::ClaudeFable5.max_token_count(), 1_000_000); assert_eq!(Model::Llama4Scout17B.max_token_count(), 128_000); assert_eq!(Model::NovaPremier.max_token_count(), 1_000_000); } @@ -1055,6 +1106,7 @@ mod tests { fn test_max_output_tokens() { assert_eq!(Model::ClaudeSonnet4_5.max_output_tokens(), 64_000); assert_eq!(Model::ClaudeOpus4_6.max_output_tokens(), 128_000); + assert_eq!(Model::ClaudeFable5.max_output_tokens(), 128_000); assert_eq!(Model::ClaudeOpus4_1.max_output_tokens(), 32_000); assert_eq!(Model::Gemma3_4B.max_output_tokens(), 8_192); } @@ -1062,6 +1114,7 @@ mod tests { #[test] fn test_supports_tool_use() { assert!(Model::ClaudeSonnet4_5.supports_tool_use()); + assert!(Model::ClaudeFable5.supports_tool_use()); assert!(Model::NovaPro.supports_tool_use()); assert!(Model::MistralLarge3.supports_tool_use()); assert!(!Model::Gemma3_4B.supports_tool_use()); @@ -1076,6 +1129,7 @@ mod tests { fn test_supports_caching() { assert!(Model::ClaudeSonnet4_5.supports_caching()); assert!(Model::ClaudeOpus4_6.supports_caching()); + assert!(Model::ClaudeFable5.supports_caching()); assert!(!Model::Llama4Scout17B.supports_caching()); assert!(!Model::NovaPro.supports_caching()); } diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index ee673577c66..7d07261f641 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -670,6 +670,18 @@ impl LanguageModel for BedrockModel { self.model.supports_thinking() } + fn refusal_fallback_model_id(&self) -> Option<&'static str> { + if self + .model + .id() + .starts_with(anthropic::FABLE_MODEL_ID_PREFIX) + { + Some(anthropic::FABLE_FALLBACK_MODEL_ID) + } else { + None + } + } + fn supported_effort_levels(&self) -> Vec { if self.model.supports_adaptive_thinking() { vec![ From aabcd71690db4d5608eef8fcca4623b3bb10f7b1 Mon Sep 17 00:00:00 2001 From: Zak Nesler Date: Fri, 3 Jul 2026 11:19:49 -0400 Subject: [PATCH 052/197] Improve commit container at larger font sizes (#60331) At larger font sizes (e.g. I have profiles that increase the font sizes when screen sharing) the commit button in the git panel editor does not stay aligned to the bottom-right of the screen, and overlaps the other elements:
Click to show showcase https://github.com/user-attachments/assets/e73034db-07a6-42d7-b993-9d8091e50b70 Notice the extra clipping of the commit message editor element: image
This was happening because the floating icons on the right and the footer containing the commit button were positioned absolutely, used pixels instead of responsive rems, and had an offset that was calculated and did not account for changes in user rem size. So I've moved things around to be more "flexy"... basically just removing the absolute positioning and making the elements flush against one another. ## Testing I have tested this manually on macOS 27 beta 2 and Windows 11, both using various light/dark themes, window sizes, commit text, etc. ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [ ] ~~Tests cover the new/changed behavior~~ - [x] Performance impact has been considered and is acceptable ## Showcase Here's a demo at different zoom levels, with and without text, when expanding the commit window, etc.
Click to view showcase https://github.com/user-attachments/assets/bea82da1-0c7f-4d51-a73b-d4bc4581a2b7 And here's another demo with debug colors for the different elements to see what's going on: https://github.com/user-attachments/assets/12fd3e95-5089-4e34-a84c-c352219ee197
--- Release Notes: - Git Panel: Fixed overlapping elements in commit message container --------- Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> Co-authored-by: Danilo Leal --- crates/git_ui/src/git_panel.rs | 174 +++++++++++++++------------------ 1 file changed, 77 insertions(+), 97 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 445b991a132..f7b36a9fc9d 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -5169,14 +5169,6 @@ impl GitPanel { let branch = active_repository.read(cx).branch.clone(); let head_commit = active_repository.read(cx).head_commit.clone(); - let footer_size = px(32.); - let gap = px(9.0); - let max_height = panel_editor_style - .text - .line_height_in_pixels(window.rem_size()) - * MAX_PANEL_EDITOR_LINES - + gap; - let git_panel = cx.entity(); let display_name = SharedString::from(Arc::from( active_repository @@ -5200,6 +5192,58 @@ impl GitPanel { false }; + let vertical_buttons = v_flex() + .h_full() + .gap_px() + .p_1p5() + .opacity(0.6) + .hover(|s| s.opacity(1.0)) + .child( + IconButton::new("expand-commit-editor", IconName::MaximizeAlt) + .icon_size(IconSize::Small) + .tooltip({ + move |_window, cx| { + Tooltip::for_action_in( + "Open Commit Modal", + &git::ExpandCommitEditor, + &editor_focus_handle, + cx, + ) + } + }) + .on_click(cx.listener({ + move |_, _, window, cx| { + window.dispatch_action(git::ExpandCommitEditor.boxed_clone(), cx) + } + })), + ) + .child({ + let (icon, label) = if self.commit_editor_expanded { + (IconName::Minimize, "Collapse Commit Editor") + } else { + (IconName::Maximize, "Expand Commit Editor") + }; + let focus_handle = self.focus_handle.clone(); + + IconButton::new("fill-commit-editor", icon) + .icon_size(IconSize::Small) + .tooltip({ + move |_window, cx| { + Tooltip::for_action_in( + label, + &git::ToggleFillCommitEditor, + &focus_handle, + cx, + ) + } + }) + .on_click(cx.listener({ + move |_, _, window, cx| { + window.dispatch_action(git::ToggleFillCommitEditor.boxed_clone(), cx) + } + })) + }); + let footer = v_flex() .when(self.commit_editor_expanded, |this| this.flex_1().min_h_0()) .child(PanelRepoFooter::new( @@ -5233,13 +5277,8 @@ impl GitPanel { .child( panel_editor_container(window, cx) .id("commit-editor-container") - .cursor_text() - .relative() .w_full() .when(self.commit_editor_expanded, |this| this.flex_1().min_h_0()) - .when(!self.commit_editor_expanded, |this| { - this.h(max_height + footer_size) - }) .border_t_1() .border_color(if title_exceeds_limit { cx.theme().status().warning_border @@ -5249,20 +5288,37 @@ impl GitPanel { .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| { window.focus(&this.commit_editor.focus_handle(cx), cx); })) + .child( + h_flex() + .size_full() + .child( + div() + .pt_2() + .px_2() + .cursor_text() + .flex_grow_1() + .on_action(|&zed_actions::editor::MoveUp, _, cx| { + cx.stop_propagation(); + }) + .on_action(|&zed_actions::editor::MoveDown, _, cx| { + cx.stop_propagation(); + }) + .child(EditorElement::new( + &self.commit_editor, + panel_editor_style, + )), + ) + .child(vertical_buttons), + ) .child( h_flex() .id("commit-footer") + .w_full() + .p_1p5() .border_t_1() .when(editor_is_long, |el| { el.border_color(cx.theme().colors().border_variant) }) - .absolute() - .bottom_0() - .left_0() - .w_full() - .px_2() - .h(footer_size) - .flex_none() .justify_between() .child( self.render_generate_commit_message_button(cx) @@ -5274,80 +5330,6 @@ impl GitPanel { .children(enable_coauthors) .child(self.render_commit_button(cx)), ), - ) - .child( - div() - .when(self.commit_editor_expanded, |this| { - this.flex_1().min_h_0().pb(footer_size) - }) - .pr_2p5() - .on_action(|&zed_actions::editor::MoveUp, _, cx| { - cx.stop_propagation(); - }) - .on_action(|&zed_actions::editor::MoveDown, _, cx| { - cx.stop_propagation(); - }) - .child(EditorElement::new(&self.commit_editor, panel_editor_style)), - ) - .child( - v_flex() - .absolute() - .top_2() - .right_2() - .gap_px() - .opacity(0.6) - .hover(|s| s.opacity(1.0)) - .child( - IconButton::new("expand-commit-editor", IconName::MaximizeAlt) - .icon_size(IconSize::Small) - .tooltip({ - move |_window, cx| { - Tooltip::for_action_in( - "Open Commit Modal", - &git::ExpandCommitEditor, - &editor_focus_handle, - cx, - ) - } - }) - .on_click(cx.listener({ - move |_, _, window, cx| { - window.dispatch_action( - git::ExpandCommitEditor.boxed_clone(), - cx, - ) - } - })), - ) - .child({ - let (icon, label) = if self.commit_editor_expanded { - (IconName::Minimize, "Collapse Commit Editor") - } else { - (IconName::Maximize, "Expand Commit Editor") - }; - let focus_handle = self.focus_handle.clone(); - - IconButton::new("fill-commit-editor", icon) - .icon_size(IconSize::Small) - .tooltip({ - move |_window, cx| { - Tooltip::for_action_in( - label, - &git::ToggleFillCommitEditor, - &focus_handle, - cx, - ) - } - }) - .on_click(cx.listener({ - move |_, _, window, cx| { - window.dispatch_action( - git::ToggleFillCommitEditor.boxed_clone(), - cx, - ) - } - })) - }), ), ); @@ -7468,8 +7450,6 @@ impl PanelHeader for GitPanel {} pub fn panel_editor_container(_window: &mut Window, cx: &mut App) -> Div { v_flex() .size_full() - .gap(px(8.)) - .p_2() .bg(cx.theme().colors().editor_background) } @@ -7689,9 +7669,9 @@ impl RenderOnce for PanelRepoFooter { }); h_flex() - .h_9() .w_full() .px_2() + .py_1p5() .justify_between() .gap_1() .child( From f4364d870e8e805ff272ab25a198ab46db19e51b Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Fri, 3 Jul 2026 17:20:09 +0200 Subject: [PATCH 053/197] gpui_linux: Add support for open_window in headless client (#60359) This actually makes it possible for Linux headless client to have working windows for computation (well, we are in headless mode after all). This also matches macOS headless client behaviour. Release Notes: - N/A --- crates/gpui_linux/src/linux/headless.rs | 1 + .../gpui_linux/src/linux/headless/client.rs | 19 +- .../gpui_linux/src/linux/headless/window.rs | 287 ++++++++++++++++++ 3 files changed, 301 insertions(+), 6 deletions(-) create mode 100644 crates/gpui_linux/src/linux/headless/window.rs diff --git a/crates/gpui_linux/src/linux/headless.rs b/crates/gpui_linux/src/linux/headless.rs index 2237aeb1941..6667a3b5a1f 100644 --- a/crates/gpui_linux/src/linux/headless.rs +++ b/crates/gpui_linux/src/linux/headless.rs @@ -1,3 +1,4 @@ mod client; +mod window; pub(crate) use client::*; diff --git a/crates/gpui_linux/src/linux/headless/client.rs b/crates/gpui_linux/src/linux/headless/client.rs index e127da1f2f2..06da3cbd945 100644 --- a/crates/gpui_linux/src/linux/headless/client.rs +++ b/crates/gpui_linux/src/linux/headless/client.rs @@ -4,6 +4,7 @@ use std::rc::Rc; use calloop::{EventLoop, LoopHandle}; use gpui_util::ResultExt; +use crate::linux::headless::window::{HeadlessDisplay, HeadlessWindow}; use crate::linux::{LinuxClient, LinuxCommon, LinuxKeyboardLayout}; use gpui::{ AnyWindowHandle, CursorStyle, DisplayId, PlatformDisplay, PlatformKeyboardLayout, @@ -14,6 +15,7 @@ pub struct HeadlessClientState { pub(crate) _loop_handle: LoopHandle<'static, HeadlessClient>, pub(crate) event_loop: Option>, pub(crate) common: LinuxCommon, + pub(crate) display: Rc, } #[derive(Clone)] @@ -47,6 +49,7 @@ impl HeadlessClient { event_loop: Some(event_loop), _loop_handle: handle, common, + display: Rc::new(HeadlessDisplay::new()), }))) } } @@ -61,15 +64,16 @@ impl LinuxClient for HeadlessClient { } fn displays(&self) -> Vec> { - vec![] + vec![self.0.borrow().display.clone()] } fn primary_display(&self) -> Option> { - None + Some(self.0.borrow().display.clone()) } - fn display(&self, _id: DisplayId) -> Option> { - None + fn display(&self, id: DisplayId) -> Option> { + let display = self.0.borrow().display.clone(); + (display.id() == id).then_some(display) } #[cfg(feature = "screen-capture")] @@ -96,9 +100,12 @@ impl LinuxClient for HeadlessClient { fn open_window( &self, _handle: AnyWindowHandle, - _params: WindowParams, + params: WindowParams, ) -> anyhow::Result> { - anyhow::bail!("neither DISPLAY nor WAYLAND_DISPLAY is set. You can run in headless mode"); + Ok(Box::new(HeadlessWindow::new( + params, + self.0.borrow().display.clone(), + ))) } fn compositor_name(&self) -> &'static str { diff --git a/crates/gpui_linux/src/linux/headless/window.rs b/crates/gpui_linux/src/linux/headless/window.rs new file mode 100644 index 00000000000..e41b09723de --- /dev/null +++ b/crates/gpui_linux/src/linux/headless/window.rs @@ -0,0 +1,287 @@ +//! Windows for the headless platform client. +//! +//! A headless window has no compositor surface and no GPU: layout, text +//! shaping, and entity plumbing run normally, `draw` discards the scene, and +//! the sprite atlas hands out tiles without uploading pixels (mirroring +//! GPUI's `TestWindow`/`TestAtlas`). This lets command-line tools drive real +//! `Window`-based code paths without a display server. + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; + +use collections::HashMap; +use parking_lot::Mutex; +use uuid::Uuid; + +use gpui::{ + AtlasKey, AtlasTextureId, AtlasTile, Bounds, Capslock, DevicePixels, DispatchEventResult, + DisplayId, GpuSpecs, Modifiers, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, + PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, + Scene, Size, TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds, + WindowControlArea, WindowParams, px, +}; + +#[derive(Debug)] +pub(crate) struct HeadlessDisplay { + bounds: Bounds, +} + +impl HeadlessDisplay { + pub(crate) fn new() -> Self { + Self { + bounds: Bounds::from_corners(Point::default(), Point::new(px(1920.), px(1080.))), + } + } +} + +impl PlatformDisplay for HeadlessDisplay { + fn id(&self) -> DisplayId { + DisplayId::new(0) + } + + fn uuid(&self) -> anyhow::Result { + // Stable identity: there is exactly one headless display. + Ok(Uuid::nil()) + } + + fn bounds(&self) -> Bounds { + self.bounds + } +} + +struct HeadlessWindowState { + bounds: Bounds, + display: Rc, + input_handler: Option, + title: Option, + is_fullscreen: bool, +} + +pub(crate) struct HeadlessWindow(Rc>); + +impl raw_window_handle::HasWindowHandle for HeadlessWindow { + fn window_handle( + &self, + ) -> Result, raw_window_handle::HandleError> { + // Headless windows are not backed by a native window. + Err(raw_window_handle::HandleError::NotSupported) + } +} + +impl raw_window_handle::HasDisplayHandle for HeadlessWindow { + fn display_handle( + &self, + ) -> Result, raw_window_handle::HandleError> { + Err(raw_window_handle::HandleError::NotSupported) + } +} + +impl HeadlessWindow { + pub(crate) fn new(params: WindowParams, display: Rc) -> Self { + Self(Rc::new(RefCell::new(HeadlessWindowState { + bounds: params.bounds, + display, + input_handler: None, + title: None, + is_fullscreen: false, + }))) + } +} + +impl PlatformWindow for HeadlessWindow { + fn bounds(&self) -> Bounds { + self.0.borrow().bounds + } + + fn is_maximized(&self) -> bool { + false + } + + fn window_bounds(&self) -> WindowBounds { + WindowBounds::Windowed(self.bounds()) + } + + fn content_size(&self) -> Size { + self.bounds().size + } + + fn resize(&mut self, size: Size) { + self.0.borrow_mut().bounds.size = size; + } + + fn scale_factor(&self) -> f32 { + 1.0 + } + + fn appearance(&self) -> WindowAppearance { + WindowAppearance::Dark + } + + fn display(&self) -> Option> { + Some(self.0.borrow().display.clone()) + } + + fn mouse_position(&self) -> Point { + Point::default() + } + + fn modifiers(&self) -> Modifiers { + Modifiers::default() + } + + fn capslock(&self) -> Capslock { + Capslock::default() + } + + fn set_input_handler(&mut self, input_handler: PlatformInputHandler) { + self.0.borrow_mut().input_handler = Some(input_handler); + } + + fn take_input_handler(&mut self) -> Option { + self.0.borrow_mut().input_handler.take() + } + + fn prompt( + &self, + _level: PromptLevel, + _msg: &str, + _detail: Option<&str>, + _answers: &[PromptButton], + ) -> Option> { + // Fall back to GPUI's rendered prompts. + None + } + + fn activate(&self) {} + + fn is_active(&self) -> bool { + false + } + + fn is_hovered(&self) -> bool { + false + } + + fn background_appearance(&self) -> WindowBackgroundAppearance { + WindowBackgroundAppearance::Opaque + } + + fn set_title(&mut self, title: &str) { + self.0.borrow_mut().title = Some(title.to_owned()); + } + + fn get_title(&self) -> String { + self.0.borrow().title.clone().unwrap_or_default() + } + + fn set_background_appearance(&self, _background: WindowBackgroundAppearance) {} + + fn minimize(&self) {} + + fn zoom(&self) {} + + fn toggle_fullscreen(&self) { + let mut state = self.0.borrow_mut(); + state.is_fullscreen = !state.is_fullscreen; + } + + fn is_fullscreen(&self) -> bool { + self.0.borrow().is_fullscreen + } + + // No compositor drives a frame loop, so frame and status callbacks are + // dropped: anything that awaits a frame will never resolve headlessly. + fn on_request_frame(&self, _callback: Box) {} + + fn on_input(&self, _callback: Box DispatchEventResult>) {} + + fn on_active_status_change(&self, _callback: Box) {} + + fn on_hover_status_change(&self, _callback: Box) {} + + fn on_resize(&self, _callback: Box, f32)>) {} + + fn on_moved(&self, _callback: Box) {} + + fn on_should_close(&self, _callback: Box bool>) {} + + fn on_close(&self, _callback: Box) {} + + fn on_hit_test_window_control(&self, _callback: Box Option>) { + } + + fn on_appearance_changed(&self, _callback: Box) {} + + fn draw(&self, _scene: &Scene) {} + + fn sprite_atlas(&self) -> Arc { + Arc::new(HeadlessAtlas::default()) + } + + fn is_subpixel_rendering_supported(&self) -> bool { + false + } + + fn update_ime_position(&self, _bounds: Bounds) {} + + fn gpu_specs(&self) -> Option { + None + } +} + +/// Allocates atlas tiles without uploading pixels, so glyph and sprite +/// painting completes headlessly. +#[derive(Default)] +struct HeadlessAtlas(Mutex); + +#[derive(Default)] +struct HeadlessAtlasState { + next_id: u32, + tiles: HashMap, +} + +impl PlatformAtlas for HeadlessAtlas { + fn get_or_insert_with<'a>( + &self, + key: &AtlasKey, + build: &mut dyn FnMut() -> anyhow::Result< + Option<(Size, std::borrow::Cow<'a, [u8]>)>, + >, + ) -> anyhow::Result> { + { + let state = self.0.lock(); + if let Some(&tile) = state.tiles.get(key) { + return Ok(Some(tile)); + } + } + + let Some((size, _)) = build()? else { + return Ok(None); + }; + + let mut state = self.0.lock(); + state.next_id += 1; + let texture_id = state.next_id; + state.next_id += 1; + let tile_id = state.next_id; + let tile = AtlasTile { + texture_id: AtlasTextureId { + index: texture_id, + kind: key.texture_kind(), + }, + tile_id: TileId(tile_id), + padding: 0, + bounds: Bounds { + origin: Point::default(), + size, + }, + }; + state.tiles.insert(key.clone(), tile); + Ok(Some(tile)) + } + + fn remove(&self, key: &AtlasKey) { + self.0.lock().tiles.remove(key); + } +} From 53552b29a44275428a3a990ee1d4a9b37e728331 Mon Sep 17 00:00:00 2001 From: Hamza Paracha <86984199+DevDonzo@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:21:52 -0400 Subject: [PATCH 054/197] docs: Clarify agent notification placement (#54032) ## Summary - describe agent notifications as OS desktop notifications instead of claiming a fixed screen position - keep the agent panel docs accurate across platforms and desktop environments Fixes #53588 Release Notes: - N/A --------- Co-authored-by: Smit Barmase --- docs/src/ai/agent-panel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md index e8f6115e02b..a26189de217 100644 --- a/docs/src/ai/agent-panel.md +++ b/docs/src/ai/agent-panel.md @@ -106,7 +106,7 @@ You can also hold `cmd`/`ctrl` when submitting a message to automatically follow If you send a prompt to the Agent and then put Zed in the background, you can choose to be notified when its generation wraps up via: -- a visual notification that appears in the top right of your screen +- a visual desktop notification from your operating system - a sound notification These notifications can be used together or individually, and you can use the `agent.notify_when_agent_waiting` and `agent.play_sound_when_agent_done` settings keys to customize that, including turning both off entirely. From 814b152a0f40b6bc64996e8f728c9c3de8e8104e Mon Sep 17 00:00:00 2001 From: Neel Date: Fri, 3 Jul 2026 16:26:15 +0100 Subject: [PATCH 055/197] bedrock: Add Claude Sonnet 5 (#60360) Also orders the Claude models from most to least capable. Release Notes: - Added Sonnet 5 to Bedrock provider Co-authored-by: David Irvine --- crates/bedrock/src/models.rs | 408 ++++++++++++++++++++--------------- 1 file changed, 230 insertions(+), 178 deletions(-) diff --git a/crates/bedrock/src/models.rs b/crates/bedrock/src/models.rs index 9ebeffda7a8..57c1eea0166 100644 --- a/crates/bedrock/src/models.rs +++ b/crates/bedrock/src/models.rs @@ -48,15 +48,62 @@ pub struct BedrockModelCacheConfiguration { #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)] pub enum Model { // Anthropic Claude 4+ models - #[serde(rename = "claude-haiku-4-5", alias = "claude-haiku-4-5-latest")] - ClaudeHaiku4_5, #[serde( - rename = "claude-sonnet-4", - alias = "claude-sonnet-4-latest", - alias = "claude-sonnet-4-thinking", - alias = "claude-sonnet-4-thinking-latest" + rename = "claude-fable-5", + alias = "claude-fable-5-latest", + alias = "claude-fable-5-thinking", + alias = "claude-fable-5-thinking-latest" )] - ClaudeSonnet4, + ClaudeFable5, + #[serde( + rename = "claude-opus-4-8", + alias = "claude-opus-4-8-latest", + alias = "claude-opus-4-8-thinking", + alias = "claude-opus-4-8-thinking-latest" + )] + ClaudeOpus4_8, + #[serde( + rename = "claude-opus-4-7", + alias = "claude-opus-4-7-latest", + alias = "claude-opus-4-7-thinking", + alias = "claude-opus-4-7-thinking-latest" + )] + ClaudeOpus4_7, + #[serde( + rename = "claude-opus-4-6", + alias = "claude-opus-4-6-latest", + alias = "claude-opus-4-6-thinking", + alias = "claude-opus-4-6-thinking-latest" + )] + ClaudeOpus4_6, + #[serde( + rename = "claude-opus-4-5", + alias = "claude-opus-4-5-latest", + alias = "claude-opus-4-5-thinking", + alias = "claude-opus-4-5-thinking-latest" + )] + ClaudeOpus4_5, + #[serde( + rename = "claude-opus-4-1", + alias = "claude-opus-4-1-latest", + alias = "claude-opus-4-1-thinking", + alias = "claude-opus-4-1-thinking-latest" + )] + ClaudeOpus4_1, + #[serde( + rename = "claude-sonnet-5", + alias = "claude-sonnet-5-latest", + alias = "claude-sonnet-5-thinking", + alias = "claude-sonnet-5-thinking-latest" + )] + ClaudeSonnet5, + #[serde( + rename = "claude-sonnet-4-6", + alias = "claude-sonnet-4-6-latest", + alias = "claude-sonnet-4-6-thinking", + alias = "claude-sonnet-4-6-thinking-latest" + )] + ClaudeSonnet4_6, #[default] #[serde( rename = "claude-sonnet-4-5", @@ -66,54 +113,14 @@ pub enum Model { )] ClaudeSonnet4_5, #[serde( - rename = "claude-opus-4-1", - alias = "claude-opus-4-1-latest", - alias = "claude-opus-4-1-thinking", - alias = "claude-opus-4-1-thinking-latest" + rename = "claude-sonnet-4", + alias = "claude-sonnet-4-latest", + alias = "claude-sonnet-4-thinking", + alias = "claude-sonnet-4-thinking-latest" )] - ClaudeOpus4_1, - #[serde( - rename = "claude-opus-4-5", - alias = "claude-opus-4-5-latest", - alias = "claude-opus-4-5-thinking", - alias = "claude-opus-4-5-thinking-latest" - )] - ClaudeOpus4_5, - #[serde( - rename = "claude-opus-4-6", - alias = "claude-opus-4-6-latest", - alias = "claude-opus-4-6-thinking", - alias = "claude-opus-4-6-thinking-latest" - )] - ClaudeOpus4_6, - #[serde( - rename = "claude-opus-4-7", - alias = "claude-opus-4-7-latest", - alias = "claude-opus-4-7-thinking", - alias = "claude-opus-4-7-thinking-latest" - )] - ClaudeOpus4_7, - #[serde( - rename = "claude-opus-4-8", - alias = "claude-opus-4-8-latest", - alias = "claude-opus-4-8-thinking", - alias = "claude-opus-4-8-thinking-latest" - )] - ClaudeOpus4_8, - #[serde( - rename = "claude-sonnet-4-6", - alias = "claude-sonnet-4-6-latest", - alias = "claude-sonnet-4-6-thinking", - alias = "claude-sonnet-4-6-thinking-latest" - )] - ClaudeSonnet4_6, - #[serde( - rename = "claude-fable-5", - alias = "claude-fable-5-latest", - alias = "claude-fable-5-thinking", - alias = "claude-fable-5-thinking-latest" - )] - ClaudeFable5, + ClaudeSonnet4, + #[serde(rename = "claude-haiku-4-5", alias = "claude-haiku-4-5-latest")] + ClaudeHaiku4_5, // Meta Llama 4 models #[serde(rename = "llama-4-scout-17b")] @@ -238,6 +245,8 @@ impl Model { Ok(Self::ClaudeOpus4_5) } else if id.starts_with("claude-opus-4-1") { Ok(Self::ClaudeOpus4_1) + } else if id.starts_with("claude-sonnet-5") { + Ok(Self::ClaudeSonnet5) } else if id.starts_with("claude-sonnet-4-6") { Ok(Self::ClaudeSonnet4_6) } else if id.starts_with("claude-sonnet-4-5") { @@ -253,16 +262,17 @@ impl Model { pub fn id(&self) -> &str { match self { - Self::ClaudeHaiku4_5 => "claude-haiku-4-5", - Self::ClaudeSonnet4 => "claude-sonnet-4", - Self::ClaudeSonnet4_5 => "claude-sonnet-4-5", - Self::ClaudeOpus4_1 => "claude-opus-4-1", - Self::ClaudeOpus4_5 => "claude-opus-4-5", - Self::ClaudeOpus4_6 => "claude-opus-4-6", - Self::ClaudeOpus4_7 => "claude-opus-4-7", - Self::ClaudeOpus4_8 => "claude-opus-4-8", - Self::ClaudeSonnet4_6 => "claude-sonnet-4-6", Self::ClaudeFable5 => "claude-fable-5", + Self::ClaudeOpus4_8 => "claude-opus-4-8", + Self::ClaudeOpus4_7 => "claude-opus-4-7", + Self::ClaudeOpus4_6 => "claude-opus-4-6", + Self::ClaudeOpus4_5 => "claude-opus-4-5", + Self::ClaudeOpus4_1 => "claude-opus-4-1", + Self::ClaudeSonnet5 => "claude-sonnet-5", + Self::ClaudeSonnet4_6 => "claude-sonnet-4-6", + Self::ClaudeSonnet4_5 => "claude-sonnet-4-5", + Self::ClaudeSonnet4 => "claude-sonnet-4", + Self::ClaudeHaiku4_5 => "claude-haiku-4-5", Self::Llama4Scout17B => "llama-4-scout-17b", Self::Llama4Maverick17B => "llama-4-maverick-17b", Self::Gemma3_4B => "gemma-3-4b", @@ -305,16 +315,17 @@ impl Model { pub fn request_id(&self) -> &str { match self { - Self::ClaudeHaiku4_5 => "anthropic.claude-haiku-4-5-20251001-v1:0", - Self::ClaudeSonnet4 => "anthropic.claude-sonnet-4-20250514-v1:0", - Self::ClaudeSonnet4_5 => "anthropic.claude-sonnet-4-5-20250929-v1:0", - Self::ClaudeOpus4_1 => "anthropic.claude-opus-4-1-20250805-v1:0", - Self::ClaudeOpus4_5 => "anthropic.claude-opus-4-5-20251101-v1:0", - Self::ClaudeOpus4_6 => "anthropic.claude-opus-4-6-v1", - Self::ClaudeOpus4_7 => "anthropic.claude-opus-4-7", - Self::ClaudeOpus4_8 => "anthropic.claude-opus-4-8", - Self::ClaudeSonnet4_6 => "anthropic.claude-sonnet-4-6", Self::ClaudeFable5 => "anthropic.claude-fable-5", + Self::ClaudeOpus4_8 => "anthropic.claude-opus-4-8", + Self::ClaudeOpus4_7 => "anthropic.claude-opus-4-7", + Self::ClaudeOpus4_6 => "anthropic.claude-opus-4-6-v1", + Self::ClaudeOpus4_5 => "anthropic.claude-opus-4-5-20251101-v1:0", + Self::ClaudeOpus4_1 => "anthropic.claude-opus-4-1-20250805-v1:0", + Self::ClaudeSonnet5 => "anthropic.claude-sonnet-5", + Self::ClaudeSonnet4_6 => "anthropic.claude-sonnet-4-6", + Self::ClaudeSonnet4_5 => "anthropic.claude-sonnet-4-5-20250929-v1:0", + Self::ClaudeSonnet4 => "anthropic.claude-sonnet-4-20250514-v1:0", + Self::ClaudeHaiku4_5 => "anthropic.claude-haiku-4-5-20251001-v1:0", Self::Llama4Scout17B => "meta.llama4-scout-17b-instruct-v1:0", Self::Llama4Maverick17B => "meta.llama4-maverick-17b-instruct-v1:0", Self::Gemma3_4B => "google.gemma-3-4b-it", @@ -357,16 +368,17 @@ impl Model { pub fn display_name(&self) -> &str { match self { - Self::ClaudeHaiku4_5 => "Claude Haiku 4.5", - Self::ClaudeSonnet4 => "Claude Sonnet 4", - Self::ClaudeSonnet4_5 => "Claude Sonnet 4.5", - Self::ClaudeOpus4_1 => "Claude Opus 4.1", - Self::ClaudeOpus4_5 => "Claude Opus 4.5", - Self::ClaudeOpus4_6 => "Claude Opus 4.6", - Self::ClaudeOpus4_7 => "Claude Opus 4.7", - Self::ClaudeOpus4_8 => "Claude Opus 4.8", - Self::ClaudeSonnet4_6 => "Claude Sonnet 4.6", Self::ClaudeFable5 => "Claude Fable 5", + Self::ClaudeOpus4_8 => "Claude Opus 4.8", + Self::ClaudeOpus4_7 => "Claude Opus 4.7", + Self::ClaudeOpus4_6 => "Claude Opus 4.6", + Self::ClaudeOpus4_5 => "Claude Opus 4.5", + Self::ClaudeOpus4_1 => "Claude Opus 4.1", + Self::ClaudeSonnet5 => "Claude Sonnet 5", + Self::ClaudeSonnet4_6 => "Claude Sonnet 4.6", + Self::ClaudeSonnet4_5 => "Claude Sonnet 4.5", + Self::ClaudeSonnet4 => "Claude Sonnet 4", + Self::ClaudeHaiku4_5 => "Claude Haiku 4.5", Self::Llama4Scout17B => "Llama 4 Scout 17B", Self::Llama4Maverick17B => "Llama 4 Maverick 17B", Self::Gemma3_4B => "Gemma 3 4B", @@ -411,15 +423,16 @@ impl Model { pub fn max_token_count(&self) -> u64 { match self { - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeSonnet5 | Self::ClaudeSonnet4_6 - | Self::ClaudeFable5 => 1_000_000, + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 => 1_000_000, Self::ClaudeOpus4_1 => 200_000, Self::Llama4Scout17B | Self::Llama4Maverick17B => 128_000, Self::Gemma3_4B | Self::Gemma3_12B | Self::Gemma3_27B => 128_000, @@ -447,16 +460,17 @@ impl Model { pub fn max_output_tokens(&self) -> u64 { match self { - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_5 - | Self::ClaudeSonnet4_6 => 64_000, - Self::ClaudeOpus4_1 => 32_000, - Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 | Self::ClaudeOpus4_8 - | Self::ClaudeFable5 => 128_000, + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeSonnet5 => 128_000, + Self::ClaudeOpus4_5 + | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 => 64_000, + Self::ClaudeOpus4_1 => 32_000, Self::Llama4Scout17B | Self::Llama4Maverick17B | Self::Gemma3_4B @@ -488,16 +502,17 @@ impl Model { pub fn default_temperature(&self) -> f32 { match self { - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_1 + | Self::ClaudeSonnet5 | Self::ClaudeSonnet4_6 - | Self::ClaudeFable5 => 1.0, + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 => 1.0, Self::Custom { default_temperature, .. @@ -508,16 +523,17 @@ impl Model { pub fn supports_tool_use(&self) -> bool { match self { - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_1 + | Self::ClaudeSonnet5 | Self::ClaudeSonnet4_6 - | Self::ClaudeFable5 => true, + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 => true, Self::NovaLite | Self::NovaPro | Self::NovaPremier | Self::Nova2Lite => true, Self::MistralLarge3 | Self::PixtralLarge | Self::MagistralSmall => true, Self::Devstral2_123B | Self::Ministral14B => true, @@ -541,16 +557,17 @@ impl Model { pub fn supports_images(&self) -> bool { match self { - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_1 + | Self::ClaudeSonnet5 | Self::ClaudeSonnet4_6 - | Self::ClaudeFable5 => true, + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 => true, Self::NovaLite | Self::NovaPro => true, Self::PixtralLarge => true, Self::Qwen3VL235B => true, @@ -561,16 +578,17 @@ impl Model { pub fn supports_caching(&self) -> bool { match self { - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_1 + | Self::ClaudeSonnet5 | Self::ClaudeSonnet4_6 - | Self::ClaudeFable5 => true, + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 => true, Self::Custom { cache_configuration, .. @@ -582,32 +600,37 @@ impl Model { pub fn supports_thinking(&self) -> bool { matches!( self, - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_1 + | Self::ClaudeSonnet5 | Self::ClaudeSonnet4_6 - | Self::ClaudeFable5 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 ) } pub fn supports_adaptive_thinking(&self) -> bool { matches!( self, - Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeSonnet5 | Self::ClaudeSonnet4_6 - | Self::ClaudeFable5 ) } pub fn supports_xhigh_adaptive_thinking(&self) -> bool { - matches!(self, Self::ClaudeOpus4_8 | Self::ClaudeFable5) + matches!( + self, + Self::ClaudeFable5 | Self::ClaudeOpus4_8 | Self::ClaudeSonnet5 + ) } pub fn thinking_mode(&self) -> BedrockModelMode { @@ -633,15 +656,16 @@ impl Model { let supports_global = matches!( self, - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeSonnet5 | Self::ClaudeSonnet4_6 - | Self::ClaudeFable5 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 | Self::Nova2Lite ); @@ -695,15 +719,16 @@ impl Model { // Global inference profiles ( - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeSonnet5 | Self::ClaudeSonnet4_6 - | Self::ClaudeFable5 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 | Self::Nova2Lite, "global", ) => Ok(format!("{}.{}", region_group, model_id)), @@ -713,16 +738,17 @@ impl Model { // US region inference profiles ( - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_1 + | Self::ClaudeSonnet5 | Self::ClaudeSonnet4_6 - | Self::ClaudeFable5 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 | Self::Llama4Scout17B | Self::Llama4Maverick17B | Self::NovaLite @@ -739,14 +765,15 @@ impl Model { // EU region inference profiles ( - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 + Self::ClaudeFable5 | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeSonnet5 | Self::ClaudeSonnet4_6 - | Self::ClaudeFable5 + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 | Self::NovaLite | Self::NovaPro | Self::Nova2Lite, @@ -755,29 +782,29 @@ impl Model { // Australia region inference profiles ( - Self::ClaudeHaiku4_5 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_6 + Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 - | Self::ClaudeOpus4_8 - | Self::ClaudeSonnet4_6, + | Self::ClaudeOpus4_6 + | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_5 + | Self::ClaudeHaiku4_5, "au", ) => Ok(format!("{}.{}", region_group, model_id)), // Japan region inference profiles ( - Self::ClaudeHaiku4_5 + Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_5 - | Self::ClaudeSonnet4_6 + | Self::ClaudeHaiku4_5 | Self::Nova2Lite, "jp", ) => Ok(format!("{}.{}", region_group, model_id)), // APAC region inference profiles (other than AU/JP) ( - Self::ClaudeHaiku4_5 + Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 + | Self::ClaudeHaiku4_5 | Self::NovaLite | Self::NovaPro | Self::Nova2Lite, @@ -808,6 +835,10 @@ mod tests { Model::ClaudeFable5.cross_region_inference_id("us-east-1", false)?, "us.anthropic.claude-fable-5" ); + assert_eq!( + Model::ClaudeSonnet5.cross_region_inference_id("us-east-1", false)?, + "us.anthropic.claude-sonnet-5" + ); assert_eq!( Model::NovaPro.cross_region_inference_id("us-east-2", false)?, "us.amazon.nova-pro-v1:0" @@ -849,6 +880,10 @@ mod tests { Model::ClaudeFable5.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-fable-5" ); + assert_eq!( + Model::ClaudeSonnet5.cross_region_inference_id("eu-west-1", false)?, + "eu.anthropic.claude-sonnet-5" + ); Ok(()) } @@ -959,6 +994,10 @@ mod tests { Model::ClaudeFable5.cross_region_inference_id("us-east-1", true)?, "global.anthropic.claude-fable-5" ); + assert_eq!( + Model::ClaudeSonnet5.cross_region_inference_id("us-east-1", true)?, + "global.anthropic.claude-sonnet-5" + ); assert_eq!( Model::Nova2Lite.cross_region_inference_id("us-east-1", true)?, "global.amazon.nova-2-lite-v1:0" @@ -1031,6 +1070,7 @@ mod tests { assert_eq!(Model::DeepSeekR1.id(), "deepseek-r1"); assert_eq!(Model::Llama4Scout17B.id(), "llama-4-scout-17b"); assert_eq!(Model::ClaudeFable5.id(), "claude-fable-5"); + assert_eq!(Model::ClaudeSonnet5.id(), "claude-sonnet-5"); assert_eq!( Model::ClaudeSonnet4_5.request_id(), @@ -1043,6 +1083,10 @@ mod tests { "meta.llama4-scout-17b-instruct-v1:0" ); assert_eq!(Model::ClaudeFable5.request_id(), "anthropic.claude-fable-5"); + assert_eq!( + Model::ClaudeSonnet5.request_id(), + "anthropic.claude-sonnet-5" + ); // Thinking aliases deserialize to the same model assert_eq!(Model::ClaudeSonnet4.id(), "claude-sonnet-4"); @@ -1054,6 +1098,10 @@ mod tests { Model::from_id("claude-fable-5-thinking").unwrap().id(), "claude-fable-5" ); + assert_eq!( + Model::from_id("claude-sonnet-5-thinking").unwrap().id(), + "claude-sonnet-5" + ); } #[test] @@ -1068,8 +1116,10 @@ mod tests { assert!(Model::ClaudeOpus4_6.supports_adaptive_thinking()); assert!(Model::ClaudeSonnet4_6.supports_adaptive_thinking()); assert!(Model::ClaudeFable5.supports_adaptive_thinking()); + assert!(Model::ClaudeSonnet5.supports_adaptive_thinking()); assert!(!Model::ClaudeOpus4_7.supports_xhigh_adaptive_thinking()); assert!(Model::ClaudeFable5.supports_xhigh_adaptive_thinking()); + assert!(Model::ClaudeSonnet5.supports_xhigh_adaptive_thinking()); assert!(Model::ClaudeOpus4_8.supports_xhigh_adaptive_thinking()); assert_eq!(BedrockAdaptiveThinkingEffort::XHigh.as_str(), "xhigh"); @@ -1098,6 +1148,7 @@ mod tests { assert_eq!(Model::ClaudeSonnet4_5.max_token_count(), 1_000_000); assert_eq!(Model::ClaudeOpus4_6.max_token_count(), 1_000_000); assert_eq!(Model::ClaudeFable5.max_token_count(), 1_000_000); + assert_eq!(Model::ClaudeSonnet5.max_token_count(), 1_000_000); assert_eq!(Model::Llama4Scout17B.max_token_count(), 128_000); assert_eq!(Model::NovaPremier.max_token_count(), 1_000_000); } @@ -1107,6 +1158,7 @@ mod tests { assert_eq!(Model::ClaudeSonnet4_5.max_output_tokens(), 64_000); assert_eq!(Model::ClaudeOpus4_6.max_output_tokens(), 128_000); assert_eq!(Model::ClaudeFable5.max_output_tokens(), 128_000); + assert_eq!(Model::ClaudeSonnet5.max_output_tokens(), 128_000); assert_eq!(Model::ClaudeOpus4_1.max_output_tokens(), 32_000); assert_eq!(Model::Gemma3_4B.max_output_tokens(), 8_192); } From 6eaad52c29a026591b18acfc0ef6f35bce85d676 Mon Sep 17 00:00:00 2001 From: Yi Liu Date: Sat, 4 Jul 2026 00:41:58 +0800 Subject: [PATCH 056/197] language_models: Avoid sending Bedrock cache-point-only messages (#59436) When prompt caching is enabled, `into_bedrock` pushes a `CachePoint` block onto a message's content whenever `message.cache && supports_caching` is true. This push happens before the `if bedrock_message_content.is_empty() { continue; }` guard. As a result, a message whose content filters down to empty (for example, a message that contained only content stripped during conversion) still gets appended to the request carrying nothing but a `cachePoint`. Bedrock rejects such a message with a `ValidationException`, breaking the whole request. The Anthropic path is already internally consistent here: in `crates/language_models/src/provider/anthropic.rs` (around the message assembly in `completion.rs`) the empty-content check runs first, so an empty message is dropped before any cache marker is attached. The Bedrock path should behave the same way. This change gates the `CachePoint` push on non-empty content (`&& !bedrock_message_content.is_empty()`), so an empty message is left empty and then skipped by the existing `continue`, exactly mirroring the Anthropic ordering. The fix is minimal and touches only the cache-point condition. Release Notes: - Fixed Bedrock requests failing with ValidationException when the last message filtered to empty content while prompt caching was enabled. --------- Signed-off-by: Yi LIU Co-authored-by: Smit Barmase --- .../language_models/src/provider/bedrock.rs | 90 ++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index 7d07261f641..ba3b587151a 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -1025,7 +1025,7 @@ pub fn into_bedrock( } }) .collect(); - if message.cache && supports_caching { + if message.cache && supports_caching && !bedrock_message_content.is_empty() { bedrock_message_content.push(BedrockInnerContent::CachePoint( CachePointBlock::builder() .r#type(CachePointType::Default) @@ -1747,3 +1747,91 @@ impl ConfigurationView { ) } } + +#[cfg(test)] +mod tests { + use super::*; + use language_model::LanguageModelRequestMessage; + + fn into_bedrock_request(messages: Vec) -> bedrock::Request { + into_bedrock( + LanguageModelRequest { + messages, + ..Default::default() + }, + "claude-sonnet-4-5".to_string(), + 1.0, + 4096, + BedrockModelMode::Default, + true, + true, + None, + None, + ) + .unwrap() + } + + #[test] + fn test_cache_marked_message_that_filters_to_empty_is_dropped() { + let request = into_bedrock_request(vec![ + LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("What's the weather?".into())], + cache: false, + reasoning_details: None, + }, + LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![MessageContent::Thinking { + text: "Let me think about this...".into(), + signature: None, + }], + cache: true, + reasoning_details: None, + }, + LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Summarize this conversation.".into())], + cache: false, + reasoning_details: None, + }, + ]); + + for message in &request.messages { + assert!( + message + .content() + .iter() + .any(|block| !matches!(block, BedrockInnerContent::CachePoint(_))), + "message must not consist solely of cache points: {:?}", + message + ); + } + assert!( + request + .messages + .iter() + .all(|message| *message.role() == bedrock::BedrockRole::User), + "the assistant message stripped to empty content should be dropped entirely" + ); + } + + #[test] + fn test_cache_marked_message_with_content_gets_cache_point() { + let request = into_bedrock_request(vec![LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("What's the weather?".into())], + cache: true, + reasoning_details: None, + }]); + + assert_eq!(request.messages.len(), 1); + assert!( + matches!( + request.messages[0].content().last(), + Some(BedrockInnerContent::CachePoint(_)) + ), + "a cache-marked message with content should end with a cache point" + ); + } +} From 262fb9ba2a58859c2eef7814249f4d266b065179 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:55:24 -0700 Subject: [PATCH 057/197] Show per-file diff stat in multibuffer headers (#60299) Renders the per-file added/removed line counts (already tracked per buffer by the multibuffer) in each diff multibuffer header, matching the overall stat shown in the branch diff toolbar. ### Before Screenshot 2026-07-02 at 11 50 42
AM ### After Screenshot 2026-07-02 at 12 03 58
PM Release Notes: - Improved diff multibuffer headers to show per-file added/removed line counts --------- Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- crates/editor/src/element/header.rs | 91 +++++++++++++++++++---------- 1 file changed, 61 insertions(+), 30 deletions(-) diff --git a/crates/editor/src/element/header.rs b/crates/editor/src/element/header.rs index e51f29514a0..9340ee6ac11 100644 --- a/crates/editor/src/element/header.rs +++ b/crates/editor/src/element/header.rs @@ -20,8 +20,8 @@ use sum_tree::Bias; use text::BufferId; use theme::ActiveTheme; use ui::{ - ButtonLike, ContextMenu, Indicator, KeyBinding, Tooltip, prelude::*, right_click_menu, - text_for_keystroke, + ButtonLike, ContextMenu, DiffStat, Indicator, KeyBinding, Tooltip, prelude::*, + right_click_menu, text_for_keystroke, }; use util::ResultExt; use workspace::{ItemHandle, ItemSettings, OpenInTerminal, OpenTerminal, RevealInProjectPanel}; @@ -624,6 +624,13 @@ pub(crate) fn render_buffer_header( window: &mut Window, cx: &mut App, ) -> impl IntoElement { + let buffer_id = for_excerpt.buffer_id(); + let header_hovered_state = window.use_keyed_state( + ("buffer-header-hovered", buffer_id.to_proto()), + cx, + |_, _| false, + ); + let header_hovered = *header_hovered_state.read(cx); let editor_read = editor.read(cx); let multi_buffer = editor_read.buffer.read(cx); let is_read_only = editor_read.read_only(cx); @@ -637,11 +644,16 @@ pub(crate) fn render_buffer_header( None }; - let buffer_id = for_excerpt.buffer_id(); let file_status = multi_buffer .all_diff_hunks_expanded() .then(|| editor_read.status_for_buffer_id(buffer_id, cx)) .flatten(); + let diff_stat = multi_buffer + .all_diff_hunks_expanded() + .then(|| multibuffer_snapshot.diff_for_buffer_id(buffer_id)) + .flatten() + .map(|diff| diff.changed_row_counts()) + .filter(|(added, removed)| *added > 0 || *removed > 0); let indicator = multi_buffer.buffer(buffer_id).and_then(|buffer| { let buffer = buffer.read(cx); let indicator_color = match (buffer.has_conflict(), buffer.is_dirty()) { @@ -681,6 +693,14 @@ pub(crate) fn render_buffer_header( let header = div() .id(("buffer-header", buffer_id.to_proto())) + .on_hover(move |hovered, _window, cx| { + header_hovered_state.update(cx, |state, cx| { + if *state != *hovered { + *state = *hovered; + cx.notify(); + } + }); + }) .p(BUFFER_HEADER_PADDING) .w_full() .h(FILE_HEADER_HEIGHT as f32 * window.line_height()) @@ -886,36 +906,47 @@ pub(crate) fn render_buffer_header( }) }, )) - .when(can_open_excerpts && relative_path.is_some(), |this| { + .when_some(diff_stat, |this, (added, removed)| { this.child( - div() - .when(!is_selected, |this| { - this.visible_on_hover("buffer-header-group") - }) - .child( - Button::new("open-file-button", "Open File") - .style(ButtonStyle::OutlinedGhost) - .when(is_selected, |this| { - this.key_binding(KeyBinding::for_action_in( - &OpenExcerpts, - &focus_handle, - cx, - )) - }) - .on_click(window.listener_for(editor, { - let jump_data = jump_data.clone(); - move |editor, e: &ClickEvent, window, cx| { - editor.open_excerpts_common( - Some(jump_data.clone()), - e.modifiers().secondary(), - window, - cx, - ); - } - })), - ), + div().flex_shrink_0().child( + DiffStat::new( + ("buffer-header-diff-stat", buffer_id.to_proto()), + added as usize, + removed as usize, + ) + .label_size(LabelSize::Small), + ), ) }) + .when( + can_open_excerpts + && relative_path.is_some() + && (is_selected || header_hovered), + |this| { + this.child( + Button::new("open-file-button", "Open File") + .style(ButtonStyle::OutlinedGhost) + .when(is_selected, |this| { + this.key_binding(KeyBinding::for_action_in( + &OpenExcerpts, + &focus_handle, + cx, + )) + }) + .on_click(window.listener_for(editor, { + let jump_data = jump_data.clone(); + move |editor, e: &ClickEvent, window, cx| { + editor.open_excerpts_common( + Some(jump_data.clone()), + e.modifiers().secondary(), + window, + cx, + ); + } + })), + ) + }, + ) .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .on_click(window.listener_for(editor, { let buffer_id = for_excerpt.buffer_id(); From 12e1e24434ebecaa5d81e6d8f08f4c9c4f8cbc14 Mon Sep 17 00:00:00 2001 From: bgfraser <78576904+bgfraser@users.noreply.github.com> Date: Sat, 4 Jul 2026 04:24:54 +1000 Subject: [PATCH 058/197] Initiate MCP OAuth flow on post-initialize 401 responses (#60236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP servers may accept `initialize` unauthenticated but return `401` with a `WWW-Authenticate` challenge only on a later request such as `tools/list` or `tools/call`. Previously Zed only started the Auth flow when `initialize` itself was challenged, so these servers failed opaquely and stayed stuck in `Running` with a dead client. `ContextServerStore` now handles `TransportError::AuthRequired` returned from any request, not just startup: it runs Auth discovery and transitions the server into `AuthRequired` so the UI offers to authenticate. The discovery logic is shared with the startup path, and the tool/prompt request sites route their errors through it. Release Notes: - Fixed MCP servers that require auth only on tool calls (not on `initialize`) failing to prompt for authentication. --------- Co-authored-by: Tom Houlé --- crates/context_server/src/client.rs | 39 +- crates/context_server/src/context_server.rs | 11 + crates/context_server/src/protocol.rs | 17 +- crates/context_server/src/transport.rs | 14 + crates/context_server/src/transport/http.rs | 28 +- crates/project/src/context_server_store.rs | 192 ++++++-- .../tests/integration/context_server_store.rs | 415 ++++++++++++++++++ 7 files changed, 672 insertions(+), 44 deletions(-) diff --git a/crates/context_server/src/client.rs b/crates/context_server/src/client.rs index b8a321d01b5..3761f19ec4a 100644 --- a/crates/context_server/src/client.rs +++ b/crates/context_server/src/client.rs @@ -4,7 +4,7 @@ use futures::{FutureExt, StreamExt, channel::oneshot, future, select}; use futures_lite::future::yield_now; use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, Task}; use parking_lot::Mutex; -use postage::barrier; +use postage::{barrier, prelude::Stream as _}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::{Value, value::RawValue}; use slotmap::SlotMap; @@ -21,6 +21,7 @@ use std::{ use util::{ResultExt, TryFutureExt}; use crate::{ + oauth::WwwAuthenticate, transport::{StdioTransport, Transport}, types::{CancelledParams, ClientNotification, Notification as _, notifications::Cancelled}, }; @@ -56,19 +57,19 @@ pub(crate) struct Client { #[allow(clippy::type_complexity)] #[allow(dead_code)] io_tasks: Mutex>, Task>)>>, - #[allow(dead_code)] output_done_rx: Mutex>, executor: BackgroundExecutor, - #[allow(dead_code)] transport: Arc, request_timeout: Option, /// Single-slot side channel for the last transport-level error. When the /// output task encounters a send failure it stashes the error here and - /// exits; the next request to observe cancellation `.take()`s it so it can - /// propagate a typed error (e.g. `TransportError::AuthRequired`) instead - /// of a generic "cancelled". This works because `initialize` is the sole - /// in-flight request at startup, but would need rethinking if concurrent - /// requests are ever issued during that phase. + /// exits; the next request to observe cancellation `.take()`s it so it + /// can fail with the underlying cause (e.g. "connection refused") instead + /// of a generic "cancelled". This is best-effort diagnostics: with + /// concurrent requests in flight, a single arbitrary one receives the + /// stashed error. Nothing may depend on it for correctness — + /// authentication challenges are observed via [`Self::wait_for_shutdown`] + /// and [`Transport::auth_challenge`], which do not involve requests. last_transport_error: Arc>>, } @@ -348,6 +349,28 @@ impl Client { Ok(()) } + /// A future that resolves once the transport's output loop has terminated + /// — after a send failure, or when this client is dropped — yielding the + /// authentication challenge recorded by the transport if it shut down on a + /// `401 Unauthorized` response. + /// + /// Unlike `last_transport_error`, this does not require a request to be in + /// flight when the transport fails. Returns `None` if the shutdown signal + /// was already claimed: there is a single signal per client. + pub(crate) fn wait_for_shutdown( + &self, + ) -> Option>> { + let mut output_done = self.output_done_rx.lock().take()?; + let transport = self.transport.clone(); + Some( + async move { + output_done.recv().await; + transport.auth_challenge() + } + .boxed(), + ) + } + /// Sends a JSON-RPC request to the context server and waits for a response. /// This function handles serialization, deserialization, timeout, and error handling. pub async fn request( diff --git a/crates/context_server/src/context_server.rs b/crates/context_server/src/context_server.rs index 05a3451ea86..865f779b160 100644 --- a/crates/context_server/src/context_server.rs +++ b/crates/context_server/src/context_server.rs @@ -21,6 +21,7 @@ use parking_lot::RwLock; pub use settings::ContextServerCommand; use url::Url; +use crate::oauth::WwwAuthenticate; use crate::transport::HttpTransport; #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -106,6 +107,16 @@ impl ContextServer { self.client.read().clone() } + /// The authentication challenge from the last `401 Unauthorized` response + /// this server's transport gave up on, if any. See + /// [`crate::transport::Transport::auth_challenge`]. + pub fn auth_challenge(&self) -> Option { + match &self.configuration { + ContextServerTransport::Stdio(..) => None, + ContextServerTransport::Custom(transport) => transport.auth_challenge(), + } + } + pub async fn start(&self, cx: &AsyncApp) -> Result<()> { self.initialize(self.new_client(cx)?).await } diff --git a/crates/context_server/src/protocol.rs b/crates/context_server/src/protocol.rs index 05082637c27..6eb4a84a46c 100644 --- a/crates/context_server/src/protocol.rs +++ b/crates/context_server/src/protocol.rs @@ -8,11 +8,12 @@ use std::time::Duration; use anyhow::Result; -use futures::channel::oneshot; +use futures::{channel::oneshot, future::BoxFuture}; use gpui::AsyncApp; use serde_json::Value; use crate::client::{Client, NotificationSubscription}; +use crate::oauth::WwwAuthenticate; use crate::types::{self, Notification, Request}; pub struct ModelContextProtocol { @@ -122,6 +123,20 @@ impl InitializedContextServerProtocol { self.inner.notify(T::METHOD, params) } + /// A future that resolves once the underlying transport's output loop has + /// terminated — after a send failure, or when the client is dropped — + /// yielding the authentication challenge recorded by the transport if it + /// shut down on a `401 Unauthorized` response. + /// + /// Servers may accept `initialize` unauthenticated and only challenge a + /// later request or notification. Awaiting this is what lets the owner of + /// the connection notice such a challenge even when no request was in + /// flight to carry a typed error back. Returns `None` if the shutdown + /// signal was already claimed: there is a single signal per client. + pub fn wait_for_shutdown(&self) -> Option>> { + self.inner.wait_for_shutdown() + } + pub fn on_notification( &self, method: &'static str, diff --git a/crates/context_server/src/transport.rs b/crates/context_server/src/transport.rs index bffd7e4c4d8..edb8a5e8da0 100644 --- a/crates/context_server/src/transport.rs +++ b/crates/context_server/src/transport.rs @@ -6,6 +6,8 @@ use async_trait::async_trait; use futures::Stream; use std::pin::Pin; +use crate::oauth::WwwAuthenticate; + pub use http::*; pub use stdio_transport::*; @@ -19,4 +21,16 @@ pub trait Transport: Send + Sync { /// need the negotiated version (currently only HTTP, which must attach an /// `MCP-Protocol-Version` header from 2025-06-18 onward) can pick it up. fn set_protocol_version(&self, _version: &str) {} + + /// The authentication challenge from the last `401 Unauthorized` response + /// this transport gave up on, if any (currently only set by the HTTP + /// transport). + /// + /// The challenge is recorded right before the failed send tears down the + /// client's output loop. Observers of the client's shutdown read it from + /// here, so a 401 can initiate the OAuth flow even when it arrived on a + /// notification, with no request in flight to carry a typed error. + fn auth_challenge(&self) -> Option { + None + } } diff --git a/crates/context_server/src/transport/http.rs b/crates/context_server/src/transport/http.rs index bf374586b35..aa47a73fc38 100644 --- a/crates/context_server/src/transport/http.rs +++ b/crates/context_server/src/transport/http.rs @@ -58,6 +58,10 @@ pub struct HttpTransport { /// When set, the transport attaches `Authorization: Bearer` headers and /// handles 401 responses with token refresh + retry. token_provider: Option>, + /// The challenge from the last 401 this transport gave up on; cleared at + /// the start of each send so it always describes the most recent attempt. + /// See [`Transport::auth_challenge`]. + auth_challenge: SyncMutex>, } impl HttpTransport { @@ -92,6 +96,7 @@ impl HttpTransport { error_rx, headers, token_provider, + auth_challenge: SyncMutex::new(None), } } @@ -133,8 +138,21 @@ impl HttpTransport { Ok(request_builder.body(AsyncBody::from(message.to_vec()))?) } + /// Record the challenge so it remains observable after the failed send + /// tears down the client (see [`Transport::auth_challenge`]), and build + /// the typed error for the send itself. + fn auth_required(&self, www_authenticate: WwwAuthenticate) -> anyhow::Error { + *self.auth_challenge.lock() = Some(www_authenticate.clone()); + TransportError::AuthRequired { www_authenticate }.into() + } + /// Send a message and handle the response based on content type. async fn send_message(&self, message: String) -> Result<()> { + // The same server instance can be restarted over this transport; a + // challenge recorded by a previous client generation must not be + // observed by the current one. + *self.auth_challenge.lock() = None; + let is_notification = !message.contains("\"id\":") || message.contains("notifications/initialized"); @@ -174,13 +192,13 @@ impl HttpTransport { // If still 401 after refresh, give up. if response.status().as_u16() == 401 { - return Err(TransportError::AuthRequired { www_authenticate }.into()); + return Err(self.auth_required(www_authenticate)); } } else { - return Err(TransportError::AuthRequired { www_authenticate }.into()); + return Err(self.auth_required(www_authenticate)); } } else { - return Err(TransportError::AuthRequired { www_authenticate }.into()); + return Err(self.auth_required(www_authenticate)); } } @@ -335,6 +353,10 @@ impl Transport for HttpTransport { fn set_protocol_version(&self, version: &str) { *self.protocol_version.lock() = Some(version.to_string()); } + + fn auth_challenge(&self) -> Option { + self.auth_challenge.lock().clone() + } } impl Drop for HttpTransport { diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index ef5077d53a2..bbd53e1e733 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -8,7 +8,7 @@ use std::time::Duration; use anyhow::{Context as _, Result}; use collections::{HashMap, HashSet}; use context_server::oauth::{self, McpOAuthTokenProvider, OAuthDiscovery, OAuthSession}; -use context_server::transport::{HttpTransport, TransportError}; +use context_server::transport::HttpTransport; use context_server::{ContextServer, ContextServerCommand, ContextServerId}; use credentials_provider::CredentialsProvider; use futures::future::Either; @@ -93,6 +93,9 @@ enum ContextServerState { Running { server: Arc, configuration: Arc, + /// Initiates the OAuth flow if the transport shuts down on an + /// authentication challenge; cancelled by any state transition. + _transport_watch: Task<()>, }, Stopped { server: Arc, @@ -708,9 +711,12 @@ impl ContextServerStore { let new_state = match server.clone().start(cx).await { Ok(_) => { debug_assert!(server.client().is_some()); + let _transport_watch = + Self::watch_transport_shutdown(this.clone(), server.clone(), cx); ContextServerState::Running { server, configuration, + _transport_watch, } } Err(err) => resolve_start_failure(&id, err, server, configuration, cx).await, @@ -733,6 +739,97 @@ impl ContextServerStore { ); } + /// Watches a running server's transport and initiates the OAuth flow if it + /// shuts down on an authentication challenge. + /// + /// MCP servers may accept `initialize` unauthenticated and only send a 401 + /// with a `WWW-Authenticate` challenge on a later request or notification. + /// The HTTP transport records the challenge, and the failed send tears + /// down the client's output loop. Observing that shutdown — rather than + /// relying on some request to carry a typed error back to a caller — is + /// what lets any post-initialize 401 move the server into `AuthRequired` + /// instead of leaving it `Running` with a dead client. + fn watch_transport_shutdown( + this: WeakEntity, + server: Arc, + cx: &mut AsyncApp, + ) -> Task<()> { + let Some(shutdown) = server + .client() + .and_then(|client| client.wait_for_shutdown()) + else { + return Task::ready(()); + }; + cx.spawn(async move |cx| { + let Some(www_authenticate) = shutdown.await else { + // Non-auth transport deaths leave the server state untouched, + // as they did before this watch existed. + return; + }; + this.update(cx, |this, cx| { + this.handle_auth_challenge(server, www_authenticate, cx); + }) + .log_err(); + }) + } + + fn handle_auth_challenge( + &mut self, + server: Arc, + www_authenticate: oauth::WwwAuthenticate, + cx: &mut Context, + ) { + let id = server.id(); + + // Act only if this exact server is still the one we consider running. + // If the state has changed since the challenge was recorded, whoever + // changed it owns the lifecycle now. + let Some(ContextServerState::Running { + server: running_server, + configuration, + .. + }) = self.servers.get(&id) + else { + return; + }; + if !Arc::ptr_eq(running_server, &server) { + return; + } + let configuration = configuration.clone(); + + log::info!("{id} received 401 after initialization; initiating OAuth authorization"); + + // The 401 already tore down the client's output loop. Stop the dead + // client, then resolve auth using the captured `WWW-Authenticate` — do + // not restart via `run_server`, as a fresh `initialize` would succeed + // and lose the challenge. + server.stop().log_err(); + + let task = cx.spawn({ + let id = id.clone(); + let server = server.clone(); + let configuration = configuration.clone(); + async move |this, cx| { + let new_state = + resolve_auth_required(&id, &www_authenticate, server, configuration, cx).await; + this.update(cx, |this, cx| { + this.update_server_state(id.clone(), new_state, cx) + }) + .log_err(); + } + }); + + self.update_server_state( + id, + ContextServerState::Starting { + configuration, + _task: task, + server, + }, + cx, + ); + } + fn remove_server(&mut self, id: &ContextServerId, cx: &mut Context) -> Result<()> { let state = self .servers @@ -1686,43 +1783,34 @@ async fn resolve_start_failure( configuration: Arc, cx: &AsyncApp, ) -> ContextServerState { - let www_authenticate = err.downcast_ref::().map(|e| match e { - TransportError::AuthRequired { www_authenticate } => www_authenticate.clone(), - }); - - if www_authenticate.is_some() && configuration.has_static_auth_header() { - log::warn!("{id} received 401 with a static Authorization header configured"); - return ContextServerState::Error { - configuration, - server, - error: "Server returned 401 Unauthorized. Check your configured Authorization header." - .into(), - }; - } - - let server_url = match configuration.as_ref() { - ContextServerConfiguration::Http { url, .. } if !configuration.has_static_auth_header() => { - url.clone() - } - _ => { - if www_authenticate.is_some() { - log::error!("{id} got OAuth 401 on a non-HTTP transport or with static auth"); - } else { - log::error!("{id} context server failed to start: {err}"); - } - return ContextServerState::Error { - configuration, - server, - error: err.to_string().into(), - }; - } - }; + // Read the challenge from the transport rather than downcasting `err`: it + // is recorded before the failed send's error propagates, so a 401 is + // recognized even when another error (e.g. the request timeout) wins the + // race to become the reported startup failure. + let www_authenticate = server.auth_challenge(); // When the error is NOT a 401 but there is a cached OAuth session in the // keychain, the session is likely stale/expired and caused the failure // (e.g. timeout because the server rejected the token silently). Clear it // so the next start attempt can get a clean 401 and trigger the auth flow. + // If there is no such session this is an ordinary startup error. if www_authenticate.is_none() { + let server_url = match configuration.as_ref() { + ContextServerConfiguration::Http { url, .. } + if !configuration.has_static_auth_header() => + { + url.clone() + } + _ => { + log::error!("{id} context server failed to start: {err}"); + return ContextServerState::Error { + configuration, + server, + error: err.to_string().into(), + }; + } + }; + let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx)); match ContextServerStore::load_session(&credentials_provider, &server_url, cx).await { Ok(Some(_)) => { @@ -1751,6 +1839,46 @@ async fn resolve_start_failure( let www_authenticate = www_authenticate .as_ref() .unwrap_or(&default_www_authenticate); + + resolve_auth_required(id, www_authenticate, server, configuration, cx).await +} + +/// Runs OAuth discovery for a server that returned a 401 and produces the +/// appropriate state (`AuthRequired`, `ClientSecretRequired`, or `Error`). +/// +/// Shared by the startup path ([`resolve_start_failure`]) and the +/// post-initialize path ([`ContextServerStore::handle_auth_challenge`]) so +/// that a 401 at any point — not only during `initialize` — can initiate the +/// OAuth flow. +async fn resolve_auth_required( + id: &ContextServerId, + www_authenticate: &oauth::WwwAuthenticate, + server: Arc, + configuration: Arc, + cx: &AsyncApp, +) -> ContextServerState { + if configuration.has_static_auth_header() { + log::warn!("{id} received 401 with a static Authorization header configured"); + return ContextServerState::Error { + configuration, + server, + error: "Server returned 401 Unauthorized. Check your configured Authorization header." + .into(), + }; + } + + let server_url = match configuration.as_ref() { + ContextServerConfiguration::Http { url, .. } => url.clone(), + _ => { + log::error!("{id} got OAuth 401 on a non-HTTP transport"); + return ContextServerState::Error { + configuration, + server, + error: "Server returned 401 Unauthorized on a non-HTTP transport".into(), + }; + } + }; + let http_client = cx.update(|cx| cx.http_client()); match context_server::oauth::discover(&http_client, &server_url, www_authenticate).await { diff --git a/crates/project/tests/integration/context_server_store.rs b/crates/project/tests/integration/context_server_store.rs index 090baacf032..a91cdd2ea81 100644 --- a/crates/project/tests/integration/context_server_store.rs +++ b/crates/project/tests/integration/context_server_store.rs @@ -914,6 +914,421 @@ async fn test_remote_context_server(cx: &mut TestAppContext) { cx.run_until_parked(); } +// A server may accept `initialize` unauthenticated (returns 200) yet only send +// `WWW-Authenticate` on a later request such as `tools/list` / `tools/call`. +// That post-initialize 401 must initiate the OAuth flow instead of surfacing as +// an opaque request failure while the server stays "Running". +#[gpui::test] +async fn test_http_server_authenticates_on_post_init_401(cx: &mut TestAppContext) { + use context_server::transport::TransportError; + + const SERVER_ID: &str = "auth-server"; + let server_id = ContextServerId(SERVER_ID.into()); + + set_fake_mcp_http_client(cx, |message| { + if message.contains("\"method\":\"initialize\"") { + Ok(initialize_response()) + } else if message.contains("notifications/initialized") { + Ok(notification_accepted_response()) + } else { + Ok(unauthorized_response()) + } + }); + + let (_fs, project) = setup_context_server_test(cx, json!({ "code.rs": "" }), vec![]).await; + let store = project.read_with(cx, |project, _| project.context_server_store()); + + set_http_context_server_configuration(&server_id, cx); + + { + let _server_events = assert_server_events( + &store, + vec![ + (server_id.clone(), ContextServerStatus::Starting), + (server_id.clone(), ContextServerStatus::Running), + ], + cx, + ); + cx.run_until_parked(); + } + + let client = store.read_with(cx, |store, _| { + store + .get_running_server(&server_id) + .expect("server should be running") + .client() + .expect("running server should have a client") + }); + + { + let _server_events = assert_server_events( + &store, + vec![ + (server_id.clone(), ContextServerStatus::Starting), + (server_id.clone(), ContextServerStatus::AuthRequired), + ], + cx, + ); + + // The 401 tears down the client, and the request that carried it fails + // with the typed error. + let error = client + .request::(()) + .await + .expect_err("request challenged with a 401 should fail"); + assert!( + matches!( + error.downcast_ref::(), + Some(TransportError::AuthRequired { .. }) + ), + "expected an AuthRequired error, got: {error}" + ); + + cx.run_until_parked(); + } + + cx.update(|cx| { + assert_eq!( + store.read(cx).status_for_server(&server_id), + Some(ContextServerStatus::AuthRequired), + "server should require authentication after a post-initialize 401" + ); + }); +} + +// The first 401 may even arrive on a notification (`notifications/initialized` +// here): the send fails with no request in flight to carry a typed error back, +// and the client is dead by the time anything notices. Watching the transport +// shutdown must still move the server into `AuthRequired`. +#[gpui::test] +async fn test_http_server_authenticates_on_notification_401(cx: &mut TestAppContext) { + const SERVER_ID: &str = "auth-server"; + let server_id = ContextServerId(SERVER_ID.into()); + + set_fake_mcp_http_client(cx, |message| { + if message.contains("\"method\":\"initialize\"") { + Ok(initialize_response()) + } else { + Ok(unauthorized_response()) + } + }); + + let (_fs, project) = setup_context_server_test(cx, json!({ "code.rs": "" }), vec![]).await; + let store = project.read_with(cx, |project, _| project.context_server_store()); + + set_http_context_server_configuration(&server_id, cx); + + { + let _server_events = assert_server_events( + &store, + vec![ + (server_id.clone(), ContextServerStatus::Starting), + (server_id.clone(), ContextServerStatus::Running), + (server_id.clone(), ContextServerStatus::Starting), + (server_id.clone(), ContextServerStatus::AuthRequired), + ], + cx, + ); + cx.run_until_parked(); + } + + cx.update(|cx| { + assert_eq!( + store.read(cx).status_for_server(&server_id), + Some(ContextServerStatus::AuthRequired), + "server should require authentication after a 401 on a notification" + ); + }); +} + +// A transport failure that is not an authentication challenge must not touch +// the server's state: no spurious auth flow, and (as before the transport +// watch existed) the server stays `Running`. +#[gpui::test] +async fn test_http_server_ignores_non_auth_transport_failure(cx: &mut TestAppContext) { + const SERVER_ID: &str = "flaky-server"; + let server_id = ContextServerId(SERVER_ID.into()); + + set_fake_mcp_http_client(cx, |message| { + if message.contains("\"method\":\"initialize\"") { + Ok(initialize_response()) + } else if message.contains("notifications/initialized") { + Ok(notification_accepted_response()) + } else { + Err(anyhow::anyhow!("connection reset")) + } + }); + + let (_fs, project) = setup_context_server_test(cx, json!({ "code.rs": "" }), vec![]).await; + let store = project.read_with(cx, |project, _| project.context_server_store()); + + set_http_context_server_configuration(&server_id, cx); + + { + let _server_events = assert_server_events( + &store, + vec![ + (server_id.clone(), ContextServerStatus::Starting), + (server_id.clone(), ContextServerStatus::Running), + ], + cx, + ); + cx.run_until_parked(); + + let client = store.read_with(cx, |store, _| { + store + .get_running_server(&server_id) + .expect("server should be running") + .client() + .expect("running server should have a client") + }); + + client + .request::(()) + .await + .expect_err("request should fail when the transport errors"); + + cx.run_until_parked(); + // Dropping the events guard asserts no further status change happened. + } + + cx.update(|cx| { + assert_eq!( + store.read(cx).status_for_server(&server_id), + Some(ContextServerStatus::Running), + "a non-auth transport failure should not change the server state" + ); + }); +} + +// A server may also require authentication on `initialize` itself. The +// challenge is read from the transport slot rather than the returned error, so +// the 401 is recognized even if another error (e.g. the request timeout) wins +// the race to become the reported startup failure. +#[gpui::test] +async fn test_http_server_authenticates_on_initialize_401(cx: &mut TestAppContext) { + const SERVER_ID: &str = "auth-server"; + let server_id = ContextServerId(SERVER_ID.into()); + + set_fake_mcp_http_client(cx, |_message| Ok(unauthorized_response())); + + let (_fs, project) = setup_context_server_test(cx, json!({ "code.rs": "" }), vec![]).await; + let store = project.read_with(cx, |project, _| project.context_server_store()); + + set_http_context_server_configuration(&server_id, cx); + + { + let _server_events = assert_server_events( + &store, + vec![ + (server_id.clone(), ContextServerStatus::Starting), + (server_id.clone(), ContextServerStatus::AuthRequired), + ], + cx, + ); + cx.run_until_parked(); + } + + cx.update(|cx| { + assert_eq!( + store.read(cx).status_for_server(&server_id), + Some(ContextServerStatus::AuthRequired), + "server should require authentication after a 401 on initialize" + ); + }); +} + +// Restarting a server reuses its transport (e.g. via the MCP settings page), +// so a challenge recorded by a previous client generation must not leak into +// the next one: after a successful restart, a non-auth transport failure must +// not trip a spurious auth flow on the stale challenge. +#[gpui::test] +async fn test_http_server_restart_clears_stale_auth_challenge(cx: &mut TestAppContext) { + use std::sync::atomic::{AtomicBool, Ordering}; + + const SERVER_ID: &str = "auth-server"; + let server_id = ContextServerId(SERVER_ID.into()); + + let restarted = Arc::new(AtomicBool::new(false)); + set_fake_mcp_http_client(cx, { + let restarted = restarted.clone(); + move |message| { + if message.contains("\"method\":\"initialize\"") { + Ok(initialize_response()) + } else if message.contains("notifications/initialized") { + Ok(notification_accepted_response()) + } else if restarted.load(Ordering::SeqCst) { + Err(anyhow::anyhow!("connection reset")) + } else { + Ok(unauthorized_response()) + } + } + }); + + let (_fs, project) = setup_context_server_test(cx, json!({ "code.rs": "" }), vec![]).await; + let store = project.read_with(cx, |project, _| project.context_server_store()); + + set_http_context_server_configuration(&server_id, cx); + cx.run_until_parked(); + + // A post-initialize 401 records a challenge on the transport and moves the + // server into AuthRequired. + let client = store.read_with(cx, |store, _| { + store + .get_running_server(&server_id) + .expect("server should be running") + .client() + .expect("running server should have a client") + }); + client + .request::(()) + .await + .expect_err("request challenged with a 401 should fail"); + // Drop our handle so the dead client fully goes away, as it does in + // production once the store has stopped it: a lingering client would + // compete with its successor for the reused transport's response channel. + drop(client); + cx.run_until_parked(); + cx.update(|cx| { + assert_eq!( + store.read(cx).status_for_server(&server_id), + Some(ContextServerStatus::AuthRequired), + ); + }); + + // The user restarts the same server instance instead of authenticating, + // and the server no longer challenges. + restarted.store(true, Ordering::SeqCst); + store.update(cx, |store, cx| { + let server = store.get_server(&server_id).expect("server should exist"); + store.start_server(server, cx); + }); + cx.run_until_parked(); + cx.update(|cx| { + assert_eq!( + store.read(cx).status_for_server(&server_id), + Some(ContextServerStatus::Running), + ); + }); + + let client = store.read_with(cx, |store, _| { + store + .get_running_server(&server_id) + .expect("server should be running") + .client() + .expect("running server should have a client") + }); + client + .request::(()) + .await + .expect_err("request should fail when the transport errors"); + cx.run_until_parked(); + + cx.update(|cx| { + assert_eq!( + store.read(cx).status_for_server(&server_id), + Some(ContextServerStatus::Running), + "a stale challenge from a previous client generation must not trigger auth" + ); + }); +} + +fn set_http_context_server_configuration(server_id: &ContextServerId, cx: &mut TestAppContext) { + set_context_server_configuration( + vec![( + server_id.0.clone(), + settings::ContextServerSettingsContent::Http { + enabled: true, + url: "https://mcp.example.com/mcp".to_string(), + headers: Default::default(), + timeout: None, + oauth: None, + }, + )], + cx, + ); +} + +/// A fake HTTP client that serves the OAuth discovery documents (CIMD-capable, +/// so no dynamic client registration is needed) and routes MCP endpoint POSTs +/// to `respond_to_mcp_message` by the JSON-RPC message in the request body. +fn set_fake_mcp_http_client( + cx: &mut TestAppContext, + respond_to_mcp_message: impl Fn(&str) -> Result> + + Send + + Sync + + 'static, +) { + let respond_to_mcp_message = Arc::new(respond_to_mcp_message); + let client = FakeHttpClient::create(move |request| { + let respond_to_mcp_message = respond_to_mcp_message.clone(); + async move { + let uri = request.uri().to_string(); + let discovery_document = if uri.contains("oauth-protected-resource") { + Some(json!({ + "resource": "https://mcp.example.com", + "authorization_servers": ["https://auth.example.com"], + "scopes_supported": ["mcp:read"] + })) + } else if uri.contains("oauth-authorization-server") { + Some(json!({ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token", + "code_challenge_methods_supported": ["S256"], + "client_id_metadata_document_supported": true + })) + } else { + None + }; + if let Some(document) = discovery_document { + return Ok(json_response(document)); + } + + let mut body = request.into_body(); + let mut message = String::new(); + futures::AsyncReadExt::read_to_string(&mut body, &mut message).await?; + respond_to_mcp_message(&message) + } + }); + cx.update(|cx| cx.set_http_client(client)); +} + +fn json_response(body: serde_json::Value) -> Response { + Response::builder() + .status(200) + .header("Content-Type", "application/json") + .body(http_client::AsyncBody::from(body.to_string())) + .unwrap() +} + +fn initialize_response() -> Response { + json_response(json!({ + "jsonrpc": "2.0", + "id": 0, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "serverInfo": { "name": "test-server", "version": "1.0.0" } + } + })) +} + +fn notification_accepted_response() -> Response { + Response::builder() + .status(202) + .body(http_client::AsyncBody::empty()) + .unwrap() +} + +fn unauthorized_response() -> Response { + Response::builder() + .status(401) + .header("WWW-Authenticate", "Bearer") + .body(http_client::AsyncBody::empty()) + .unwrap() +} + struct ServerEvents { received_event_count: Rc>, expected_event_count: usize, From 2f1caadd387f403f675a131e2841d1f85e465bf3 Mon Sep 17 00:00:00 2001 From: Zak Nesler Date: Fri, 3 Jul 2026 14:26:14 -0400 Subject: [PATCH 059/197] Fix expanded commit editor (#60368) This is a simple follow-up to #60331, the commit message in the expanded view got messed up and was being shrunken and centered instead of taking up the full height of the git panel. All it needed was a `.h_full()`:
Click to view showcase ### Before file-6d7f3fd7a5de8cff5ea44c0dfbaf0ac5 ### After file-268420a52e7b967fc09d10007d6aa1e5
@danilo-leal ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [ ] ~~Tests cover the new/changed behavior~~ - [x] Performance impact has been considered and is acceptable --- Release Notes: - N/A --- crates/git_ui/src/git_panel.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index f7b36a9fc9d..60afc057b7a 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -5295,8 +5295,9 @@ impl GitPanel { div() .pt_2() .px_2() - .cursor_text() + .h_full() .flex_grow_1() + .cursor_text() .on_action(|&zed_actions::editor::MoveUp, _, cx| { cx.stop_propagation(); }) From 9eb8a7c0add822e8bc7dac64f7929be0124a2fe4 Mon Sep 17 00:00:00 2001 From: KyleBarton Date: Fri, 3 Jul 2026 11:37:06 -0700 Subject: [PATCH 060/197] Handle cases where Dockerfile aliases are chained (#57552) 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 #56189 Release Notes: - Fixed bug to handle dev container Dockerfiles with chained aliases --------- Co-authored-by: Martin Ye --- .../src/devcontainer_manifest.rs | 267 ++++++++++++++---- 1 file changed, 217 insertions(+), 50 deletions(-) diff --git a/crates/dev_container/src/devcontainer_manifest.rs b/crates/dev_container/src/devcontainer_manifest.rs index fb2e4f6cd6b..1f2b2373819 100644 --- a/crates/dev_container/src/devcontainer_manifest.rs +++ b/crates/dev_container/src/devcontainer_manifest.rs @@ -2793,54 +2793,77 @@ chmod +x ./install.sh Ok(script) } +struct ParsedFromLine<'a> { + image: &'a str, + alias: Option<&'a str>, +} + +/// Parses a `FROM` instruction into its image and optional stage alias, +/// skipping flags like `--platform=...`. Returns `None` for non-`FROM` lines. +fn parse_from_line(line: &str) -> Option> { + let mut tokens = line.split_whitespace(); + if !tokens.next()?.eq_ignore_ascii_case("FROM") { + return None; + } + let image = tokens.find(|token| !token.starts_with("--"))?; + let alias = match (tokens.next(), tokens.next()) { + (Some(keyword), Some(alias)) if keyword.eq_ignore_ascii_case("as") => Some(alias), + _ => None, + }; + Some(ParsedFromLine { image, alias }) +} + fn dockerfile_inject_alias( dockerfile_content: &str, alias: &str, build_target: Option, ) -> String { - let from_lines: Vec<(usize, &str)> = dockerfile_content + let from_lines: Vec<(usize, ParsedFromLine)> = dockerfile_content .lines() .enumerate() - .filter(|(_, line)| line.starts_with("FROM")) + .filter_map(|(index, line)| parse_from_line(line).map(|parsed| (index, parsed))) .collect(); let target_entry = match &build_target { - Some(target) => from_lines.iter().rfind(|(_, line)| { - let parts: Vec<&str> = line.split_whitespace().collect(); - parts.len() >= 3 - && parts - .get(parts.len() - 2) - .map_or(false, |p| p.eq_ignore_ascii_case("as")) - && parts - .last() - .map_or(false, |p| p.eq_ignore_ascii_case(target)) + Some(target) => from_lines.iter().rfind(|(_, parsed)| { + parsed + .alias + .is_some_and(|alias| alias.eq_ignore_ascii_case(target)) }), None => from_lines.last(), }; - let Some(&(line_idx, from_line)) = target_entry else { + let Some((line_idx, parsed)) = target_entry else { + match &build_target { + Some(target) => log::warn!( + "Build target stage {target:?} not found in Dockerfile; leaving it unmodified" + ), + None => log::warn!("No FROM instruction found in Dockerfile; leaving it unmodified"), + } return dockerfile_content.to_string(); }; - let parts: Vec<&str> = from_line.split_whitespace().collect(); - let has_alias = parts.len() >= 3 - && parts - .get(parts.len() - 2) - .map_or(false, |p| p.eq_ignore_ascii_case("as")); - - if has_alias { - let Some(existing_alias) = parts.last() else { - return dockerfile_content.to_string(); - }; + if let Some(existing_alias) = parsed.alias { format!("{dockerfile_content}\nFROM {existing_alias} AS {alias}") } else { let lines: Vec<&str> = dockerfile_content.lines().collect(); + // Appending ` AS {alias}` to a line ending in a `\` continuation would + // corrupt the instruction, so leave the Dockerfile unmodified. + if lines + .get(*line_idx) + .is_some_and(|line| line.trim_end().ends_with('\\')) + { + log::warn!( + "FROM instruction spans multiple lines via `\\` continuation; cannot inject stage alias, leaving Dockerfile unmodified" + ); + return dockerfile_content.to_string(); + } let mut result = String::new(); for (i, line) in lines.iter().enumerate() { if i > 0 { result.push('\n'); } - if i == line_idx { + if i == *line_idx { result.push_str(&format!("{line} AS {alias}")); } else { result.push_str(line); @@ -2854,29 +2877,36 @@ fn dockerfile_inject_alias( } fn image_from_dockerfile(dockerfile_contents: String, target: &Option) -> Option { - dockerfile_contents + let stages: Vec = dockerfile_contents .lines() - .filter(|line| line.starts_with("FROM")) - .rfind(|from_line| match &target { - Some(target) => { - let parts = from_line.split(' ').collect::>(); - if parts.len() >= 3 - && parts.get(parts.len() - 2).unwrap_or(&"").to_lowercase() == "as" - { - parts.last().unwrap_or(&"").to_lowercase() == target.to_lowercase() - } else { - false - } - } - None => true, - }) - .and_then(|from_line| { - from_line - .split(' ') - .collect::>() - .get(1) - .map(|s| s.to_string()) - }) + .filter_map(parse_from_line) + .collect(); + + let start_index = match target { + Some(target) => stages.iter().rposition(|stage| { + stage + .alias + .is_some_and(|alias| alias.eq_ignore_ascii_case(target)) + })?, + None => stages.len().checked_sub(1)?, + }; + + // Follow alias chains (`FROM base AS development`) to a concrete image. + // Docker only resolves names to stages defined earlier in the file, so + // resolving strictly backwards is correct and cannot cycle. + let mut index = start_index; + loop { + let image = stages.get(index)?.image; + let previous_stage = stages.get(..index)?.iter().rposition(|stage| { + stage + .alias + .is_some_and(|alias| alias.eq_ignore_ascii_case(image)) + }); + match previous_stage { + Some(previous_index) => index = previous_index, + None => return Some(image.to_string()), + } + } } fn get_remote_user_from_config( @@ -2961,8 +2991,9 @@ mod test { devcontainer_json::MountDefinition, devcontainer_manifest::{ ConfigStatus, DevContainerManifest, DockerBuildResources, DockerComposeResources, - DockerInspect, extract_feature_id, find_primary_service, get_remote_user_from_config, - image_from_dockerfile, is_local_feature_ref, resolve_compose_dockerfile, + DockerInspect, dockerfile_inject_alias, extract_feature_id, find_primary_service, + get_remote_user_from_config, image_from_dockerfile, is_local_feature_ref, + resolve_compose_dockerfile, }, docker::{ DockerClient, DockerComposeConfig, DockerComposeService, DockerComposeServiceBuild, @@ -5877,6 +5908,93 @@ FROM ${IMAGE} AS production assert_eq!(base_image, "docker.io/stuff/mybuild:latest".to_string()); } + #[test] + fn test_image_from_dockerfile_resolves_one_hop_alias() { + let dockerfile = "FROM ubuntu:24.04 AS base\nFROM base AS development".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("development".to_string())), + Some("ubuntu:24.04".to_string()) + ); + } + + #[test] + fn test_image_from_dockerfile_resolves_deep_alias_chain() { + let dockerfile = + "FROM ubuntu:24.04 AS base\nFROM base AS mid\nFROM mid AS development".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("development".to_string())), + Some("ubuntu:24.04".to_string()) + ); + } + + #[test] + fn test_image_from_dockerfile_no_target_resolves_alias() { + let dockerfile = "FROM ubuntu:24.04 AS base\nFROM base".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &None), + Some("ubuntu:24.04".to_string()) + ); + } + + #[test] + fn test_image_from_dockerfile_stage_alias_shadows_external_image() { + // The first `a` is an external image: stage `a` isn't defined yet. + // Target `a` builds from stage `b`, whose base is that external `a`. + let dockerfile = "FROM a AS b\nFROM b AS a".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("a".to_string())), + Some("a".to_string()) + ); + } + + #[test] + fn test_image_from_dockerfile_only_resolves_earlier_stages() { + // The first `ubuntu` is the external image, not the later stage. + let dockerfile = "FROM ubuntu AS build\nFROM debian AS ubuntu".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("build".to_string())), + Some("ubuntu".to_string()) + ); + } + + #[test] + fn test_image_from_dockerfile_skips_platform_flag() { + let dockerfile = + "FROM --platform=linux/amd64 ubuntu:24.04 AS base\nFROM base AS development" + .to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("development".to_string())), + Some("ubuntu:24.04".to_string()) + ); + } + + #[test] + fn test_image_from_dockerfile_missing_target() { + let dockerfile = "FROM ubuntu:24.04 AS base".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("nonexistent".to_string())), + None + ); + } + + #[test] + fn test_image_from_dockerfile_case_insensitive_alias() { + let dockerfile = "FROM ubuntu:24.04 AS Base\nFROM Base AS Development".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("development".to_string())), + Some("ubuntu:24.04".to_string()) + ); + } + + #[test] + fn test_image_from_dockerfile_scratch_base() { + let dockerfile = "FROM scratch AS builder\nFROM builder AS final".to_string(); + assert_eq!( + image_from_dockerfile(dockerfile, &Some("final".to_string())), + Some("scratch".to_string()) + ); + } + #[gpui::test] async fn test_expands_args_in_dockerfile(cx: &mut TestAppContext) { cx.executor().allow_parking(); @@ -6090,13 +6208,62 @@ RUN echo $RUBY_VERSION2 } #[test] - fn test_aliases_dockerfile_with_pre_existing_aliases_for_build() {} + fn test_aliases_dockerfile_with_pre_existing_aliases_for_build() { + let dockerfile = "FROM ubuntu:24.04 AS base\nFROM base AS development"; + + assert_eq!( + dockerfile_inject_alias(dockerfile, "dev_container_auto_added_stage_label", None), + "FROM ubuntu:24.04 AS base\nFROM base AS development\nFROM development AS dev_container_auto_added_stage_label" + ); + } #[test] - fn test_aliases_dockerfile_with_no_aliases_for_build() {} + fn test_aliases_dockerfile_with_no_aliases_for_build() { + let dockerfile = "FROM --platform=linux/amd64 ubuntu:24.04\nRUN echo ok"; + + assert_eq!( + dockerfile_inject_alias(dockerfile, "dev_container_auto_added_stage_label", None), + "FROM --platform=linux/amd64 ubuntu:24.04 AS dev_container_auto_added_stage_label\nRUN echo ok" + ); + } #[test] - fn test_aliases_dockerfile_with_build_target_specified() {} + fn test_aliases_dockerfile_with_build_target_specified() { + let dockerfile = "FROM ubuntu:24.04 AS development\nFROM ubuntu:22.04 AS production"; + + assert_eq!( + dockerfile_inject_alias( + dockerfile, + "dev_container_auto_added_stage_label", + Some("development".to_string()) + ), + "FROM ubuntu:24.04 AS development\nFROM ubuntu:22.04 AS production\nFROM development AS dev_container_auto_added_stage_label" + ); + } + + #[test] + fn test_aliases_dockerfile_with_missing_build_target_is_unmodified() { + let dockerfile = "FROM ubuntu:24.04 AS development"; + + assert_eq!( + dockerfile_inject_alias( + dockerfile, + "dev_container_auto_added_stage_label", + Some("nonexistent".to_string()) + ), + dockerfile + ); + } + + #[test] + fn test_aliases_dockerfile_with_line_continuation_is_unmodified() { + let dockerfile = "FROM ubuntu:24.04 \\\n --platform=linux/amd64\nRUN echo ok"; + + assert_eq!( + dockerfile_inject_alias(dockerfile, "dev_container_auto_added_stage_label", None), + dockerfile + ); + } pub(crate) struct RecordedExecCommand { pub(crate) _container_id: String, From fa8540ff6299c30ebb325601ad821127a314f44e Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Fri, 3 Jul 2026 20:44:17 +0200 Subject: [PATCH 061/197] Update Wasmtime dependencies (#60341) Brings in the latest LTS security and bug fixes Release Notes: - N/A --- Cargo.lock | 313 +++++++++++++++++++++++++++-------------------------- Cargo.toml | 4 +- 2 files changed, 164 insertions(+), 153 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 480b91504c3..5a64dbf6b4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2890,7 +2890,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eadd868a2ce9ca38de7eeafdcec9c7065ef89b42b32f0839278d55f35c54d1ff" dependencies = [ "heck 0.4.1", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "proc-macro2", "quote", @@ -3537,7 +3537,7 @@ name = "collections" version = "0.1.0" dependencies = [ "gpui_util", - "indexmap 2.11.4", + "indexmap 2.14.0", "rustc-hash 2.1.1", ] @@ -4175,36 +4175,36 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f81cede359311706057b689b91b59f464926de0316f389898a2b028cb494fa" +checksum = "3cd990d8a6304475bbad64534a0d418f5572f44d5f011437e6b9f1ee7d5c2570" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa6ca11305de425ea08884097b913ebe1a83875253b3c0063ce28411e226bfdc" +checksum = "ccabe4636007296721080e02d7dab46d4319638ec4e3f6f7402fcb46dc5122c6" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7537341a9a4ba9812141927be733e7254bf2318aab6597d567af9cad90609f27" +checksum = "da7ed173c870c0aea202a9830880156905a028a88df076e35ce383a8acbf90a7" dependencies = [ "cranelift-entity", ] [[package]] name = "cranelift-bitset" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d28a4ca5faf25ff821fcc768f26e68ffef505e9f71bb06e608862d941fa65086" +checksum = "800cc586df98b12c502e76707c96565e40629a5322eaa15aaa34ba05f5721e31" dependencies = [ "serde", "serde_derive", @@ -4212,9 +4212,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d891057fe1b73910c41e73b32a70fa8454092fce65942b5fa6f72aa6d5487f8a" +checksum = "ae93f863f9094ae34d2567f9edb0ae2c41d35228b286598354dd78b198868ebd" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -4242,9 +4242,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c29a66028a78eedc534b3a94e5ebfbaeb4e1f6b09038afe41bb24afd614faa4b" +checksum = "38c505162bcf77dcb859905b3eac56a1917fc3cf326424fb06e7732031e3a8ae" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -4255,24 +4255,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95809ad251fe9422087b4a72d61e584d6ab6eff44dee1335f93cfaea0bedc9ac" +checksum = "e3b786958bcb79bdb5fbae095af58f0c2da7d7895c475c991f6a6bb5a9c7e6d9" [[package]] name = "cranelift-control" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f79d0cacf063c297e5e8d5b73cb355b41b87f6d248e252d1b284e7a7b73673c2" +checksum = "2faf9a5009bce7f725ce2af7a08c4883ebac6af933e7e0aa7d84f976f4e6deb5" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2d73297a195ce3be55997c6307142c4b1e58dd0c2f18ceaa0179444024e312a" +checksum = "017271194ba5e101d626560d0d6767efd341468d1ba0f4d015f19fe64020b65b" dependencies = [ "cranelift-bitset", "serde", @@ -4281,9 +4281,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be38d1ae29ef7c5d611fc6cb694f698dc4ca44152dcaa112ec0fef8d4d34858" +checksum = "f80847f0929967f0cec82f9e0543b3901e0f0063690405891f22107b5a130fd8" dependencies = [ "cranelift-codegen", "log", @@ -4293,15 +4293,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6761926f6636209de7ac568be28b206890f2181761375b9722e0a1e7a7e1637a" +checksum = "75904abbc0e7b46d20f7a49c8042c8a4481c0db4253b99889c723c566295d506" [[package]] name = "cranelift-native" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0893472f73f0d530a28e9a573ada6d1f93b9659bb6734dfe17061ac967bd1830" +checksum = "6e0135923540574362e16f01bf40000664263840991039ff3041ba717de6cf3a" dependencies = [ "cranelift-codegen", "libc", @@ -4310,9 +4310,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.123.9" +version = "0.123.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1daccebabb1ccd034dbab0eacc0722af27d3cccc7929dea27a3546cb3562e40" +checksum = "93fb12f76c482e034f6ebefa843c914e74112f088215d8d36d33a649f9fab99b" [[package]] name = "crash-context" @@ -4630,7 +4630,7 @@ checksum = "d74b6bcf49ebbd91f1b1875b706ea46545032a14003b5557b7dfa4bbeba6766e" dependencies = [ "cc", "codespan-reporting", - "indexmap 2.11.4", + "indexmap 2.14.0", "proc-macro2", "quote", "scratch", @@ -4645,7 +4645,7 @@ checksum = "94ca2ad69673c4b35585edfa379617ac364bccd0ba0adf319811ba3a74ffa48a" dependencies = [ "clap", "codespan-reporting", - "indexmap 2.11.4", + "indexmap 2.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -4663,7 +4663,7 @@ version = "1.0.187" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a8ebf0b6138325af3ec73324cb3a48b64d57721f17291b151206782e61f66cd" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -6314,8 +6314,8 @@ dependencies = [ "tracing", "url", "util", - "wasm-encoder 0.221.3", - "wasmparser 0.221.3", + "wasm-encoder 0.252.0", + "wasmparser 0.252.0", "ztracing", ] @@ -6395,7 +6395,7 @@ dependencies = [ "tracing", "url", "util", - "wasmparser 0.221.3", + "wasmparser 0.252.0", "wasmtime", "wasmtime-wasi", "zlog", @@ -7247,7 +7247,7 @@ dependencies = [ "derive_more", "derive_setters", "gh-workflow-macros", - "indexmap 2.11.4", + "indexmap 2.14.0", "merge", "serde", "serde_json", @@ -7282,7 +7282,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" dependencies = [ "fallible-iterator", - "indexmap 2.11.4", + "indexmap 2.14.0", "stable_deref_trait", ] @@ -8116,7 +8116,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.11.4", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -8135,7 +8135,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.3.1", - "indexmap 2.11.4", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -8247,6 +8247,17 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", + "serde", + "serde_core", +] + [[package]] name = "hashlink" version = "0.8.4" @@ -8999,12 +9010,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.11.4" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -10640,7 +10651,7 @@ name = "manatee" version = "0.6.2" source = "git+https://github.com/zed-industries/merman?tag=v0.6.2-with-patches#9acc3960f04a7deeb08079d60fa8183f15e8bde1" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "nalgebra", "rustc-hash 2.1.1", "thiserror 2.0.17", @@ -10953,7 +10964,7 @@ dependencies = [ "chrono", "euclid", "htmlize", - "indexmap 2.11.4", + "indexmap 2.14.0", "json5", "lalrpop", "lalrpop-util", @@ -10979,7 +10990,7 @@ dependencies = [ "base64 0.22.1", "chrono", "dugong", - "indexmap 2.11.4", + "indexmap 2.14.0", "manatee", "merman-core", "pulldown-cmark 0.12.2", @@ -11284,7 +11295,7 @@ dependencies = [ "half", "hashbrown 0.16.1", "hexf-parse", - "indexmap 2.11.4", + "indexmap 2.14.0", "libm", "log", "num-traits", @@ -12127,7 +12138,7 @@ checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "crc32fast", "hashbrown 0.15.5", - "indexmap 2.11.4", + "indexmap 2.14.0", "memchr", ] @@ -13293,7 +13304,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset 0.4.2", - "indexmap 2.11.4", + "indexmap 2.14.0", ] [[package]] @@ -13607,7 +13618,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", - "indexmap 2.11.4", + "indexmap 2.14.0", "quick-xml 0.38.3", "serde", "time", @@ -13999,7 +14010,7 @@ dependencies = [ "gpui", "http_client", "image", - "indexmap 2.11.4", + "indexmap 2.14.0", "itertools 0.14.0", "language", "log", @@ -14451,9 +14462,9 @@ checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" [[package]] name = "pulley-interpreter" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b78fdec962b639b921badfcfe77db7d18aa3c0c1e292ac2aa268c0efe8fe683" +checksum = "558181096e0df4984f45cfc3a7087052df4a61c36089b135a08ceca9cbd352fb" dependencies = [ "cranelift-bitset", "log", @@ -14463,9 +14474,9 @@ dependencies = [ [[package]] name = "pulley-macros" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f718f4e8cd5fdfa08b3b1d2d25fe288350051be330544305f0a9b93a937b3d42" +checksum = "b5d52e2f14e168d75cdabe9bd5fb1ff18a1b119dc6699684aee895dbc3524da9" dependencies = [ "proc-macro2", "quote", @@ -16053,7 +16064,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" dependencies = [ "dyn-clone", - "indexmap 2.11.4", + "indexmap 2.14.0", "ref-cast", "schemars_derive", "serde", @@ -16443,7 +16454,7 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "itoa", "memchr", "ryu", @@ -16457,7 +16468,7 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e033097bf0d2b59a62b42c18ebbb797503839b26afdda2c4e1415cb6c813540" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "itoa", "memchr", "ryu", @@ -16527,7 +16538,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.11.4", + "indexmap 2.14.0", "schemars 0.9.0", "schemars 1.0.4", "serde_core", @@ -16554,7 +16565,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -16567,7 +16578,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -17328,7 +17339,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink 0.10.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "memchr", "once_cell", @@ -18496,7 +18507,7 @@ dependencies = [ "clap", "collections", "gpui", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "palette", "serde", @@ -18957,7 +18968,7 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "serde_core", "serde_spanned 1.0.3", "toml_datetime 0.7.3", @@ -18990,7 +19001,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -19004,7 +19015,7 @@ version = "0.23.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.14.0", "toml_datetime 0.7.3", "toml_parser", "winnow 0.7.13", @@ -20301,16 +20312,6 @@ dependencies = [ "leb128", ] -[[package]] -name = "wasm-encoder" -version = "0.221.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc8444fe4920de80a4fe5ab564fff2ae58b6b73166b89751f8c6c93509da32e5" -dependencies = [ - "leb128", - "wasmparser 0.221.3", -] - [[package]] name = "wasm-encoder" version = "0.227.1" @@ -20341,6 +20342,16 @@ dependencies = [ "wasmparser 0.244.0", ] +[[package]] +name = "wasm-encoder" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8185ae345fa5687c054626ff9a50e7089797a343d9904d1dc9820eb4c4d3196f" +dependencies = [ + "leb128fmt", + "wasmparser 0.252.0", +] + [[package]] name = "wasm-metadata" version = "0.201.0" @@ -20348,7 +20359,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fd83062c17b9f4985d438603cde0a5e8c5c8198201a6937f778b607924c7da2" dependencies = [ "anyhow", - "indexmap 2.11.4", + "indexmap 2.14.0", "serde", "serde_derive", "serde_json", @@ -20366,7 +20377,7 @@ dependencies = [ "anyhow", "auditable-serde", "flate2", - "indexmap 2.11.4", + "indexmap 2.14.0", "serde", "serde_derive", "serde_json", @@ -20383,7 +20394,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.11.4", + "indexmap 2.14.0", "wasm-encoder 0.244.0", "wasmparser 0.244.0", ] @@ -20420,23 +20431,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84e5df6dba6c0d7fafc63a450f1738451ed7a0b52295d83e868218fa286bf708" dependencies = [ "bitflags 2.10.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "semver", ] -[[package]] -name = "wasmparser" -version = "0.221.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185" -dependencies = [ - "bitflags 2.10.0", - "hashbrown 0.15.5", - "indexmap 2.11.4", - "semver", - "serde", -] - [[package]] name = "wasmparser" version = "0.227.1" @@ -20445,7 +20443,7 @@ checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2" dependencies = [ "bitflags 2.10.0", "hashbrown 0.15.5", - "indexmap 2.11.4", + "indexmap 2.14.0", "semver", ] @@ -20457,7 +20455,7 @@ checksum = "a9b1e81f3eb254cf7404a82cee6926a4a3ccc5aad80cc3d43608a070c67aa1d7" dependencies = [ "bitflags 2.10.0", "hashbrown 0.15.5", - "indexmap 2.11.4", + "indexmap 2.14.0", "semver", "serde", ] @@ -20470,10 +20468,23 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.10.0", "hashbrown 0.15.5", - "indexmap 2.11.4", + "indexmap 2.14.0", "semver", ] +[[package]] +name = "wasmparser" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "semver", + "serde", +] + [[package]] name = "wasmprinter" version = "0.236.1" @@ -20487,9 +20498,9 @@ dependencies = [ [[package]] name = "wasmtime" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b10306ead921db2c4645ff99867b7539b65e18afd8816d471547f5e6f3b09492" +checksum = "4b4442dc12aa2473def8334f0e0f2b489be52c52507c938bbdc8be69ded4ded6" dependencies = [ "addr2line", "anyhow", @@ -20500,7 +20511,7 @@ dependencies = [ "cfg-if", "encoding_rs", "hashbrown 0.15.5", - "indexmap 2.11.4", + "indexmap 2.14.0", "libc", "log", "mach2 0.4.3", @@ -20548,16 +20559,16 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7fb2c37ca263d444f33871bf0221e7de0707b2b2bb88165df6db6d58c73375f" +checksum = "5d881c3d6205898a226cc487b117f23b9ed1c7da39952d65bd5eeb6745b3789c" dependencies = [ "anyhow", "cpp_demangle", "cranelift-bitset", "cranelift-entity", "gimli", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "object", "postcard", @@ -20575,9 +20586,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-asm-macros" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19c6c0d3c8d2db554a3af8e8d413ff2815362ebce0911808ecfdaaa257438f93" +checksum = "5ab1876bcfa51d6a05dea1c13933f53cbc1e316c783fddebc859f56a736eae07" dependencies = [ "cfg-if", ] @@ -20594,9 +20605,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-macro" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e3f3752466eb0e1f97149e53bf15c0e18ff520fc0a98b4bee1680e6de1c6f0" +checksum = "8ae1407944a0b13a8a77930b5b951aa7134beccecad7efac1ef9f03adb7d1a0f" dependencies = [ "anyhow", "proc-macro2", @@ -20609,15 +20620,15 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-util" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f54018baf62f4e9c616c31f2aeadcf0c202ff691a390ad53e291ae7160b169e" +checksum = "646a53678ce6aaf6f097e18ca51f650f2841aea6d2bcd7b61931397b8b8f30db" [[package]] name = "wasmtime-internal-cranelift" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a2412f2afb0a5db2a4ac1cfff73247e240aeaa90bf41497ad0a5084b6a24eca" +checksum = "ab3495aa8300e4ca6b53f81a53ce5eff6621fd5ff8378ef9ae552d1479d57371" dependencies = [ "anyhow", "cfg-if", @@ -20642,9 +20653,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecfdc460dd5d343d88ff1ffaf65ae019feeb6124ddcfd3f39d28331068d25b1f" +checksum = "29b5e4023a6b167da157338f5f0f505945eb45e78f1cac2d4dcce0922457d7d4" dependencies = [ "anyhow", "cc", @@ -20658,9 +20669,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5abb428a71827b7f90fc64406749883ccc6e58addf6d36974d5e06942011707" +checksum = "9da71e2d573e3cc6f753a3b7bff98f425ca060c0e8071cc55c3d867a9edf3ecc" dependencies = [ "cc", "wasmtime-internal-versioned-export-macros", @@ -20668,9 +20679,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba6cc13f14c3fb83fb877cb1d5c605e93f7ec1bf7fc1a5e8b361209d2f8ca028" +checksum = "627d8f57909a4f9bb1dbe57a96229a54b89d5995353d0b321f3cb9a1a118977a" dependencies = [ "anyhow", "cfg-if", @@ -20680,24 +20691,24 @@ dependencies = [ [[package]] name = "wasmtime-internal-math" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cb209473a09f4dbd9c87bb9f18b8dcb0c9da30d12a260e3eacf7a1a53b41480" +checksum = "45b99315585a8a27125dd9b0150edb115d6f6ff0baae453c21d30822aab77f00" dependencies = [ "libm", ] [[package]] name = "wasmtime-internal-slab" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aab4df5a04752106e1ecef9d40145ef28fa033b0d5dd3c839c9b208b2d522183" +checksum = "8eaee97281dd3fe47ec3d46c16fb9fe2dd32f37d0523c2d5c484f11b348734e4" [[package]] name = "wasmtime-internal-unwinder" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5359875d29bddb6f7e65e698157714d8d35ebd8ea2a92893d05d6b062147b639" +checksum = "d0c005f82c48492b6b44fa19ee5205bd933c4f8baca41e314eca8331dd3c4fd9" dependencies = [ "anyhow", "cfg-if", @@ -20708,9 +20719,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e247bcdd69701743ba386c933b26ebad2ce912ff9cb68b5b71fdb29d39ba04a" +checksum = "7b73639a9c0c0e33a2ef942ca99b6772b48393be92bebbd0767c607e5b0a68e0" dependencies = [ "proc-macro2", "quote", @@ -20719,9 +20730,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-winch" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0298dfd9f57588222b5a92dcffe75894f1ead4e519850f176bde7fcfd105d54" +checksum = "392ca021d084c7426616ef77e1284315555f11bcbb34f416d74b0732db622811" dependencies = [ "anyhow", "cranelift-codegen", @@ -20736,22 +20747,22 @@ dependencies = [ [[package]] name = "wasmtime-internal-wit-bindgen" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1706803e83b9bae726a0f55e7c1bbf78a7421cf2da68c940c70978e91dfc0339" +checksum = "7fd4703351476262d715b72431e80d10289908e3494050071d6521267f522d97" dependencies = [ "anyhow", "bitflags 2.10.0", "heck 0.5.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "wit-parser 0.236.1", ] [[package]] name = "wasmtime-wasi" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a430602ec54d0e32fbb61d2d8c7e5885eaa9dbc1664b6ed57fb57df439810a0" +checksum = "21921b6e8e8ed876a288cb3b0b3aee68809fed8182ce26c9977ffc4af4cb77f6" dependencies = [ "anyhow", "async-trait", @@ -20780,9 +20791,9 @@ dependencies = [ [[package]] name = "wasmtime-wasi-io" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2ba5dd68962de394cf15c7fb185f138cdd685ced631a7ed8e056de3e071029" +checksum = "2cceb2d110d8de61e7b0e8c501b838d3c6403a14cdca8cb612ff1228db537c6d" dependencies = [ "anyhow", "async-trait", @@ -21082,7 +21093,7 @@ dependencies = [ "cfg_aliases 0.2.1", "document-features", "hashbrown 0.16.1", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "naga", "once_cell", @@ -21266,9 +21277,9 @@ dependencies = [ [[package]] name = "wiggle" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1979d3ed3ffc017538e518da6faa66b129f9229492981fc51004f28cb86db792" +checksum = "55a0751406b641ff50ef42d4a1ca843a03040c488c0c27f92093633447464013" dependencies = [ "anyhow", "async-trait", @@ -21281,9 +21292,9 @@ dependencies = [ [[package]] name = "wiggle-generate" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25d92ae7a084d8543aa7ccef0fac52c86481a7278d0533f7fdeaf89bd7b7e29f" +checksum = "ab62083fdcecdd0cac61b8c46e7de4f2629ebe8699fd9ce790d922cc89d50f5f" dependencies = [ "anyhow", "heck 0.5.0", @@ -21295,9 +21306,9 @@ dependencies = [ [[package]] name = "wiggle-macro" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36a1b1b93fd9ce569bb40c1eadf5c56533cebfc04ba545c8bc1e74464cff0735" +checksum = "756b7a4a7f57ee2f53e9ef3501ed0faacda4b8dcb169a921cddc8bc09ebd199e" dependencies = [ "proc-macro2", "quote", @@ -21338,9 +21349,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "winch-codegen" -version = "36.0.9" +version = "36.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e2d7ea2137be52644d9c42ca5a4899bba07c2ed2db1e66c4c1994adfe35d39e" +checksum = "61ec880b20caaa72245944b54cfb22aca111f8c805e12a7542b40d66921e5323" dependencies = [ "anyhow", "cranelift-assembler-x64", @@ -22262,7 +22273,7 @@ checksum = "d8a39a15d1ae2077688213611209849cad40e9e5cccf6e61951a425850677ff3" dependencies = [ "anyhow", "heck 0.4.1", - "indexmap 2.11.4", + "indexmap 2.14.0", "wasm-metadata 0.201.0", "wit-bindgen-core 0.22.0", "wit-component 0.201.0", @@ -22276,7 +22287,7 @@ checksum = "9d0809dc5ba19e2e98661bf32fc0addc5a3ca5bf3a6a7083aa6ba484085ff3ce" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata 0.227.1", @@ -22292,7 +22303,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata 0.244.0", @@ -22352,7 +22363,7 @@ checksum = "421c0c848a0660a8c22e2fd217929a0191f14476b68962afd2af89fd22e39825" dependencies = [ "anyhow", "bitflags 2.10.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -22371,7 +22382,7 @@ checksum = "635c3adc595422cbf2341a17fb73a319669cc8d33deed3a48368a841df86b676" dependencies = [ "anyhow", "bitflags 2.10.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -22390,7 +22401,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.10.0", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -22409,7 +22420,7 @@ checksum = "196d3ecfc4b759a8573bf86a9b3f8996b304b3732e4c7de81655f875f6efdca6" dependencies = [ "anyhow", "id-arena", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "semver", "serde", @@ -22427,7 +22438,7 @@ checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" dependencies = [ "anyhow", "id-arena", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "semver", "serde", @@ -22445,7 +22456,7 @@ checksum = "16e4833a20cd6e85d6abfea0e63a399472d6f88c6262957c17f546879a80ba15" dependencies = [ "anyhow", "id-arena", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "semver", "serde", @@ -22463,7 +22474,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.11.4", + "indexmap 2.14.0", "log", "semver", "serde", @@ -22768,7 +22779,7 @@ dependencies = [ "clap", "compliance", "gh-workflow", - "indexmap 2.11.4", + "indexmap 2.14.0", "indoc", "itertools 0.14.0", "regex", diff --git a/Cargo.toml b/Cargo.toml index 9f660975525..5e7703aaaee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -828,8 +828,8 @@ usvg = { version = "0.46.0", default-features = false } uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] } vte = { version = "0.15.0", features = ["ansi"] } walkdir = "2.5" -wasm-encoder = "0.221" -wasmparser = "0.221" +wasm-encoder = "0.252" +wasmparser = "0.252" wasmtime = { version = "36", default-features = false, features = [ "async", "demangle", From 5a823cf70ebb1d7a158c6a7ca455860cd9f6aed0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yara=20=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7=EF=B8=8F?= Date: Fri, 3 Jul 2026 20:46:05 +0200 Subject: [PATCH 062/197] docs: Add new text finder (#60189) Mention the text finder in the docs. Release Notes: - N/A --------- Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- docs/src/finding-navigating.md | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/docs/src/finding-navigating.md b/docs/src/finding-navigating.md index 23401ed93d0..a801da61317 100644 --- a/docs/src/finding-navigating.md +++ b/docs/src/finding-navigating.md @@ -23,6 +23,10 @@ The Project Panel ({#kb project_panel::ToggleFocus}) shows a tree view of your w Open any file in your project with {#kb file_finder::Toggle}. Type part of the filename or path to narrow results. +## Text Finder + +Quickly find any string in your project and open the file with {#kb project_search::OpenTextFinder}. Changed your mind and want a more detailed search with extra filters? Move to the project search using the button in the Actions menu in the right bottom corner. + ## Project Search Search across all files with {#kb pane::DeploySearch}. Type the query in the search field, then press Enter to run the search. @@ -52,15 +56,16 @@ Quickly switch between open tabs with {#kb tab_switcher::Toggle}. Tabs are sorte ## Quick Reference -| Task | Keybinding | -| ----------------- | -------------------------------- | -| Command Palette | {#kb command_palette::Toggle} | -| Open file | {#kb file_finder::Toggle} | -| Project search | {#kb pane::DeploySearch} | -| Go to definition | {#kb editor::GoToDefinition} | -| Find references | {#kb editor::FindAllReferences} | -| Symbol in file | {#kb outline::Toggle} | -| Symbol in project | {#kb project_symbols::Toggle} | -| Outline Panel | {#kb outline_panel::ToggleFocus} | -| Tab Switcher | {#kb tab_switcher::Toggle} | -| Project Panel | {#kb project_panel::ToggleFocus} | +| Task | Keybinding | +| ------------------ | ------------------------------------ | +| Command Palette | {#kb command_palette::Toggle} | +| Open file | {#kb file_finder::Toggle} | +| Project search | {#kb pane::DeploySearch} | +| Text search picker | {#kb project_search::OpenTextFinder} | +| Go to definition | {#kb editor::GoToDefinition} | +| Find references | {#kb editor::FindAllReferences} | +| Symbol in file | {#kb outline::Toggle} | +| Symbol in project | {#kb project_symbols::Toggle} | +| Outline Panel | {#kb outline_panel::ToggleFocus} | +| Tab Switcher | {#kb tab_switcher::Toggle} | +| Project Panel | {#kb project_panel::ToggleFocus} | From ea3d0f7abeb3dc5d0954d6d3fff453af5b0c7af9 Mon Sep 17 00:00:00 2001 From: Tautik Agrahari Date: Sat, 4 Jul 2026 00:24:42 +0530 Subject: [PATCH 063/197] keymap: Avoid format-vs-rules collision in JetBrains overlay (#55364) the jetbrains overlay binds `ctrl-alt-l` (linux) / `cmd-alt-l` (macos) to `editor::Format` (jetbrains "reformat code"), which collides with the default `agent::OpenRulesLibrary` binding. the agent panel shows the conflicting key as the rules tooltip but pressing it formats the buffer instead of opening rules. mirrors the windows keymap by switching the linux/macos overlays to `shift-alt-l` in the AgentPanel context (with the colliding key explicitly null-ed), so the hint and the action stay in sync. closes #49764. Release Notes: - Fixed the JetBrains keymap so the agent panel "Rules" entry uses `shift-alt-l` and no longer flickers a non-functional `ctrl-alt-l` / `cmd-alt-l` shortcut. --- assets/keymaps/linux/jetbrains.json | 11 +++++++++++ assets/keymaps/macos/jetbrains.json | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/assets/keymaps/linux/jetbrains.json b/assets/keymaps/linux/jetbrains.json index de4d538d40d..889f89def32 100644 --- a/assets/keymaps/linux/jetbrains.json +++ b/assets/keymaps/linux/jetbrains.json @@ -190,6 +190,17 @@ { "context": "DebugPanel", "bindings": { "alt-5": "workspace::CloseActiveDock" } }, { "context": "Diagnostics > Editor", "bindings": { "alt-6": "pane::CloseActiveItem" } }, { "context": "OutlinePanel", "bindings": { "alt-7": "workspace::CloseActiveDock" } }, + { + // `ctrl-alt-l` is bound to `editor::Format` (jetbrains "Reformat Code") in + // the Editor context above, which collides with the default + // `agent::OpenRulesLibrary` binding. Mirror the windows keymap and use + // `shift-alt-l` instead so the menu hint and the action stay in sync. + "context": "AgentPanel", + "bindings": { + "ctrl-alt-l": null, + "shift-alt-l": "agent::OpenRulesLibrary", + }, + }, { "context": "Dock || Workspace || OutlinePanel || ProjectPanel || CollabPanel", "bindings": { diff --git a/assets/keymaps/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json index 291d7d5e7a8..9df47fe2412 100644 --- a/assets/keymaps/macos/jetbrains.json +++ b/assets/keymaps/macos/jetbrains.json @@ -194,6 +194,17 @@ { "context": "DebugPanel", "bindings": { "cmd-5": "workspace::CloseActiveDock" } }, { "context": "Diagnostics > Editor", "bindings": { "cmd-6": "pane::CloseActiveItem" } }, { "context": "OutlinePanel", "bindings": { "cmd-7": "workspace::CloseActiveDock" } }, + { + // `cmd-alt-l` is bound to `editor::Format` (jetbrains "Reformat Code") in + // the Editor context above, which collides with the default + // `agent::OpenRulesLibrary` binding. Mirror the windows keymap and use + // `shift-alt-l` instead so the menu hint and the action stay in sync. + "context": "AgentPanel", + "bindings": { + "cmd-alt-l": null, + "shift-alt-l": "agent::OpenRulesLibrary", + }, + }, { "context": "Dock || Workspace || OutlinePanel || ProjectPanel || CollabPanel", "bindings": { From 5aa6e8a0b37a46828325e9f4f01ce3e0138017b3 Mon Sep 17 00:00:00 2001 From: Artin <101245159+Artin0123@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:38:42 +0800 Subject: [PATCH 064/197] Reduce OAuth scope to avoid Windows credential size limit (#58541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I encountered an issue where I couldn't sign in to ChatGPT Subscription on Windows: ``` ChatGPT subscription sign-in failed to persist credentials: Failed to write credentials to Windows Credential Manager: 占位程序接收到错误数据。 (0x800706F7) ``` Interestingly, only one of my three ChatGPT accounts had this problem — the other two signed in successfully. ## What I Found After investigating, I noticed that the OAuth scope in `openai_subscribed.rs` requests 6 permissions: ``` openid profile email offline_access api.connectors.read api.connectors.invoke ``` I wrote a test script to measure token sizes and found that: - With 6 scopes: my problematic account's token was **2578 bytes** - With 4 scopes (removing `api.connectors.read` and `api.connectors.invoke`): the same token was **2516 bytes** Since Windows Credential Manager has a 2560-byte limit (`CRED_MAX_CREDENTIAL_BLOB_SIZE`), this could explain why some accounts fail while others succeed — it depends on the base token size. ## My Hypothesis The issue seems to be that: - Error code `0x800706F7` = `RPC_X_BAD_STUB_DATA` (size limit, not format) - Accounts with larger base tokens exceed the limit when the extra 48 chars are added - Accounts with smaller base tokens still fit, which is why this isn't universally reproducible I'm not 100% certain this is the root cause, but the evidence seems to point in this direction. ## Proposed Fix I removed `api.connectors.read` and `api.connectors.invoke` from the OAuth scope, since: - These scopes don't appear to be used by Zed's current ChatGPT integration - OpenCode (another tool using the same OAuth provider) successfully uses only 4 scopes - This fix allowed my problematic account to sign in However, I'd like to ask the maintainers: - Are there plans to use OpenAI Connectors API in the future? - Are there security or compliance reasons for requesting these extra scopes? If the answer is yes to either, we'd need a different approach (e.g., splitting credentials, using encrypted file storage, or checking token size before storage). ## Testing - [x] Verified my problematic account now signs in successfully - [x] Verified my other accounts still work - [x] Ran `cargo check -p language_models` (compilation successful) - [ ] Would appreciate help testing on macOS/Linux ## Questions 1. Does this analysis make sense? 2. Are there any other considerations I'm missing? 3. Would it be helpful to add a size check with a clearer error message for future cases? I'm happy to adjust this approach based on your feedback. Release Notes: - Fixed ChatGPT Subscription sign-in failing on Windows for some accounts by removing unused OAuth scopes (`api.connectors.read`, `api.connectors.invoke`) that pushed JWT tokens over Windows Credential Manager's limit. --------- Co-authored-by: Smit Barmase --- crates/gpui_windows/src/platform.rs | 9 +++++++++ .../language_models/src/provider/openai_subscribed.rs | 10 ++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/gpui_windows/src/platform.rs b/crates/gpui_windows/src/platform.rs index 871da413e3b..9ba94563a40 100644 --- a/crates/gpui_windows/src/platform.rs +++ b/crates/gpui_windows/src/platform.rs @@ -741,6 +741,15 @@ impl Platform for WindowsPlatform { } fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task> { + // CredWriteW rejects larger blobs with the opaque RPC error + // 0x800706F7 "The stub received bad data", so fail with a clear + // message instead. + if password.len() > CRED_MAX_CREDENTIAL_BLOB_SIZE as usize { + return Task::ready(Err(anyhow!( + "credential for {url} is {} bytes, which exceeds the Windows Credential Manager limit of {CRED_MAX_CREDENTIAL_BLOB_SIZE} bytes", + password.len() + ))); + } let password = password.to_vec(); let mut username = username.encode_utf16().chain(Some(0)).collect_vec(); let mut target_name = windows_credentials_target_name(url) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 20038e61234..e3ec95859a2 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -760,10 +760,12 @@ async fn do_oauth_flow( .query_pairs_mut() .append_pair("client_id", CLIENT_ID) .append_pair("redirect_uri", &redirect_uri) - .append_pair( - "scope", - "openid profile email offline_access api.connectors.read api.connectors.invoke", - ) + // Deliberately excludes `api.connectors.read api.connectors.invoke` + // (which Codex CLI requests): extra scopes inflate the + // access-token JWT, and the serialized credentials must fit within + // Windows Credential Manager's 2560-byte blob limit + // (CRED_MAX_CREDENTIAL_BLOB_SIZE). See #58541. + .append_pair("scope", "openid profile email offline_access") .append_pair("response_type", "code") .append_pair("code_challenge", &challenge) .append_pair("code_challenge_method", "S256") From c56646ffdfffc27d0aab8b3940ef93c4153375be Mon Sep 17 00:00:00 2001 From: Gavin Luo Date: Sat, 4 Jul 2026 04:25:16 +0800 Subject: [PATCH 065/197] terminal: Fix IME candidate window not following cursor in TUI apps (#59911) # Objective Fix IME candidate window not following cursor position in the integrated terminal when running fullscreen TUI applications like opencode. When using IME (Input Method Editor) in Zed's terminal with fullscreen TUI applications (e.g., opencode), the candidate window does not follow the cursor position. This issue does not occur in normal terminal usage (e.g., bash shell). # Solution The root cause was three-layer blocking preventing IME position updates in ALT_SCREEN mode: 1. **ALT_SCREEN blocking**: `selected_text_range()` returned `None` in ALT_SCREEN mode, causing `selected_bounds()` to return `None` 2. **Missing trigger**: `Event::Wakeup` did not call `invalidate_character_coordinates()`, so cursor movement did not trigger IME position updates 3. **Composition blocking**: `update_ime_position()` skipped updates when `state.composing` was true Fixes: - Remove ALT_SCREEN check in `selected_text_range()` so IME position updates work in fullscreen TUI apps - Add `window.invalidate_character_coordinates()` in `Event::Wakeup` to trigger IME position updates when terminal cursor moves - Remove `state.composing` check in `update_ime_position()` to allow IME position updates during text-input-v3 composition - Remove unused `terminal` field from `TerminalInputHandler` struct # Testing **Did you test these changes? If so, how?** - Yes, tested on Linux GNOME Wayland with iBus input method - Verified IME candidate window correctly follows cursor in opencode - Verified normal terminal usage with IME still works correctly **Are there any parts that need more testing?** - Other IMEs (fcitx, etc.) may need testing **How can other people (reviewers) test your changes?** 1. Open Zed's integrated terminal 2. Run a fullscreen TUI application like `opencode` 3. Activate IME (e.g., iBus with Chinese input) 4. Type text and observe the IME candidate window follows the cursor **What platforms did you test these changes on?** - Linux (GNOME Wayland) - tested - macOS - not tested (may have different IME behavior) - Windows - not tested (different code path) # Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content adheres to Zed's UI standards (UX/UI and icon guidelines) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable # Showcase
Before
After
--- Release Notes: - Fixed IME candidate window not following cursor in terminal TUI apps --- crates/gpui_linux/src/linux/wayland/client.rs | 2 +- crates/terminal_view/src/terminal_element.rs | 25 ++++++------------- crates/terminal_view/src/terminal_view.rs | 1 + 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index ac637d3fc46..256875bed1e 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -388,7 +388,7 @@ impl WaylandClientStatePtr { pub fn update_ime_position(&self, bounds: Bounds) { let client = self.get_client(); let state = client.borrow_mut(); - if state.composing || state.text_input.is_none() || state.pre_edit_text.is_some() { + if state.text_input.is_none() || state.pre_edit_text.is_some() { return; } diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index cd0ed9241fa..b74d28593d0 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -1349,7 +1349,6 @@ impl Element for TerminalElement { }; let terminal_input_handler = TerminalInputHandler { - terminal: self.terminal.clone(), terminal_view: self.terminal_view.clone(), cursor_bounds: layout.ime_cursor_bounds.map(|bounds| bounds + origin), workspace: self.workspace.clone(), @@ -1510,7 +1509,6 @@ impl IntoElement for TerminalElement { } struct TerminalInputHandler { - terminal: Entity, terminal_view: Entity, workspace: WeakEntity, cursor_bounds: Option>, @@ -1521,22 +1519,15 @@ impl InputHandler for TerminalInputHandler { &mut self, _ignore_disabled_input: bool, _: &mut Window, - cx: &mut App, + _cx: &mut App, ) -> Option { - if self - .terminal - .read(cx) - .last_content - .mode - .contains(Modes::ALT_SCREEN) - { - None - } else { - Some(UTF16Selection { - range: 0..0, - reversed: false, - }) - } + // Always return a valid selection for IME positioning, + // even in ALT_SCREEN mode (fullscreen TUI apps like opencode, vim, etc.) + // The terminal still has a cursor position that should be used for IME candidate window placement. + Some(UTF16Selection { + range: 0..0, + reversed: false, + }) } fn marked_text_range( diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index ea888d564cc..3b1e6db97fc 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -1129,6 +1129,7 @@ fn subscribe_for_terminal_events( match event { Event::Wakeup => { cx.notify(); + window.invalidate_character_coordinates(); cx.emit(Event::Wakeup); cx.emit(ItemEvent::UpdateTab); cx.emit(SearchEvent::MatchesInvalidated); From b77ec90b2e6585622099235d9fb7d708d22ad956 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Fri, 3 Jul 2026 21:43:32 +0100 Subject: [PATCH 066/197] git: Do not recompute git_access on every file change (#59521) # Objective As mentioned in #59514, the git panel currently runs a full `git status` for checking whether `git` has access to the repository. This was introduced in #43693 and currently runs on every file save which is problematic for large repos where `git status` runs a lot of computation. ## Solution This PR caches the `git_access` on the git panel such that we don't need re-check for git access on every file save. ## Testing I manually verified that the git panel doesn't trigger anymore unnecessary git status commands. **main:** ``` 2026-06-18T00:04:24+01:00 DEBUG [project::git_store] start recalculating diffs for buffer 4294967643 2026-06-18T00:04:24+01:00 DEBUG [project::git_store] finished recalculating diffs for buffer 4294967643 2026-06-18T00:04:24+01:00 DEBUG [project::git_store] start recalculating diffs for buffer 4294967643 2026-06-18T00:04:24+01:00 DEBUG [project::git_store] finished recalculating diffs for buffer 4294967643 2026-06-18T00:04:24+01:00 DEBUG [project.format.local] formatting buffer '"/Users/lgeiger/code/zed/CONTRIBUTING.md"' 2026-06-18T00:04:24+01:00 DEBUG [project.format.local] no changes made while formatting 2026-06-18T00:04:24+01:00 DEBUG [git::repository] Checking for git status in ["CONTRIBUTING.md"] 2026-06-18T00:04:25+01:00 DEBUG [git::repository] Checking for git status in [] ``` ``` /opt/homebrew/bin/git -c core.fsmonitor=false -c log.showSignature=false --no-optional-locks --no-pager status --porcelain=v1 --untracked-files=all --no-renames -z -- CONTRIBUTING.md /opt/homebrew/bin/git -c core.fsmonitor=false -c log.showSignature=false --no-optional-locks --no-pager diff --numstat --no-renames HEAD -- CONTRIBUTING.md /opt/homebrew/bin/git -c core.fsmonitor=false -c log.showSignature=false --no-optional-locks --no-pager config --get commit.template /opt/homebrew/bin/git -c core.fsmonitor=false -c log.showSignature=false --no-optional-locks --no-pager status --porcelain=v1 --untracked-files=all --no-renames -z -- ``` **This PR:** ``` 2026-06-18T02:47:54+01:00 DEBUG [project::git_store] start recalculating diffs for buffer 4294967656 2026-06-18T02:47:54+01:00 DEBUG [project::git_store] finished recalculating diffs for buffer 4294967656 2026-06-18T02:47:55+01:00 DEBUG [project.format.local] formatting buffer '"/Users/lgeiger/code/zed/CONTRIBUTING.md"' 2026-06-18T02:47:55+01:00 DEBUG [project.format.local] no changes made while formatting 2026-06-18T02:47:55+01:00 DEBUG [git::repository] Checking for git status in ["CONTRIBUTING.md"] ``` ``` /opt/homebrew/bin/git -c core.fsmonitor=false -c log.showSignature=false --no-optional-locks --no-pager diff --numstat --no-renames HEAD -- CONTRIBUTING.md /opt/homebrew/bin/git -c core.fsmonitor=false -c log.showSignature=false --no-optional-locks --no-pager status --porcelain=v1 --untracked-files=all --no-renames -z -- CONTRIBUTING.md /opt/homebrew/bin/git -c core.fsmonitor=false -c log.showSignature=false --no-optional-locks --no-pager config --get commit.template ``` ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable /cc @dinocosta --- Release Notes: - Improved Git Panel performance by no longer re-checking repository access on every file change --------- Co-authored-by: dino --- crates/git_ui/src/git_panel.rs | 65 ++++++++++++++++++++------------- crates/project/src/git_store.rs | 5 ++- 2 files changed, 42 insertions(+), 28 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 60afc057b7a..6875ddea218 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -803,7 +803,7 @@ pub struct GitPanel { _repo_subscriptions: Vec, _settings_subscription: Subscription, - git_access: GitAccess, + git_access: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -1004,10 +1004,18 @@ impl GitPanel { ) | GitStoreEvent::RepositoryAdded | GitStoreEvent::RepositoryRemoved(_) - | GitStoreEvent::GlobalConfigurationUpdated | GitStoreEvent::ActiveRepositoryChanged(_) => { this.schedule_update(window, cx); } + GitStoreEvent::RepositoryUpdated( + _, + RepositoryEvent::GitDirectoryChanged, + true, + ) + | GitStoreEvent::GlobalConfigurationUpdated => { + this.git_access = None; + this.schedule_update(window, cx); + } GitStoreEvent::IndexWriteError(error) => { this.workspace .update(cx, |workspace, cx| { @@ -1075,7 +1083,7 @@ impl GitPanel { _commit_message_buffer_subscription: None, _repo_subscriptions: Vec::new(), _settings_subscription, - git_access: GitAccess::Yes, + git_access: None, }; this.schedule_update(window, cx); @@ -4122,7 +4130,11 @@ impl GitPanel { .as_ref() .and_then(|op| self.entry_by_path(&op.anchor)); - self.active_repository = self.project.read(cx).active_repository(cx); + let active_repository = self.project.read(cx).active_repository(cx); + if active_repository != self.active_repository { + self.active_repository = active_repository; + self.git_access = None; + } self.entries.clear(); self.entries_indices.clear(); self.single_staged_entry.take(); @@ -4137,7 +4149,6 @@ impl GitPanel { self.tracked_staged_count = 0; self.entry_count = 0; self.max_width_item_index = None; - self.git_access = GitAccess::Yes; let settings = GitPanelSettings::get_global(cx); let sort_by = settings.sort_by; @@ -4145,28 +4156,30 @@ impl GitPanel { let is_tree_view = matches!(self.view_mode, GitPanelViewMode::Tree(_)); if let Some(active_repo) = self.active_repository.as_ref() { - let access = active_repo.update(cx, |active_repo, cx| active_repo.access(cx)); + if self.git_access.is_none() { + let access = active_repo.update(cx, |active_repo, cx| active_repo.access(cx)); - cx.spawn_in(window, async move |git_panel, cx| { - // When the user does not own the `.git` folder, the - // `GitStore.spawn_local_git_worker` will fail to create the - // receiver for Git jobs, so this access check will be - // cancelled. - // - // We assume `GitAccess::No` on cancellation. I believe this is - // imprecise, other failures could also cause cancellation, but - // the consequence is just showing the "unsafe repo" UI, which - // seems acceptable for this edge case. - let access = match access.await { - Ok(access) => access, - Err(Canceled) => GitAccess::No, - }; + cx.spawn_in(window, async move |git_panel, cx| { + // When the user does not own the `.git` folder, the + // `GitStore.spawn_local_git_worker` will fail to create the + // receiver for Git jobs, so this access check will be + // cancelled. + // + // We assume `GitAccess::No` on cancellation. I believe this is + // imprecise, other failures could also cause cancellation, but + // the consequence is just showing the "unsafe repo" UI, which + // seems acceptable for this edge case. + let access = match access.await { + Ok(access) => access, + Err(Canceled) => GitAccess::No, + }; - git_panel.update(cx, |this, _cx| { - this.git_access = access; + git_panel.update(cx, |this, _cx| { + this.git_access = Some(access); + }) }) - }) - .detach_and_log_err(cx); + .detach_and_log_err(cx); + } } let mut changed_entries = Vec::new(); @@ -5066,7 +5079,7 @@ impl GitPanel { _window: &mut Window, cx: &mut Context, ) -> Option { - if matches!(self.git_access, GitAccess::No) { + if matches!(self.git_access, Some(GitAccess::No)) { return None; } @@ -6039,7 +6052,7 @@ impl GitPanel { fn render_empty_state(&self, cx: &mut Context) -> impl IntoElement { let content = match (self.git_access, &self.active_repository) { - (GitAccess::No, Some(repository)) => self.render_unsafe_repo_ui(repository, cx), + (Some(GitAccess::No), Some(repository)) => self.render_unsafe_repo_ui(repository, cx), (_, None) => self.render_uninitialized_ui(cx), (_, Some(_)) => self.render_no_changes_ui(cx), }; diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 778743163d6..fd3efdae5c8 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -170,14 +170,13 @@ enum DiffKind { SinceOid(Option), } -#[derive(Debug, Default, Clone, Copy)] +#[derive(Debug, Clone, Copy)] pub enum GitAccess { /// Either: /// - the user owns `.git` /// - the user doesn't own `.git`, but has both of: /// - OS-level read permissions /// - the directory is marked as safe (git config safe.directory) - #[default] Yes, /// The user is not the owner of `.git`, and one of the following is true: @@ -486,6 +485,7 @@ pub enum RepositoryEvent { GitWorktreeListChanged, PendingOpsChanged { pending_ops: SumTree }, GraphEvent((LogSource, LogOrder), GitGraphEvent), + GitDirectoryChanged, } #[derive(Clone, Debug)] @@ -2134,6 +2134,7 @@ impl GitStore { || update.new_work_directory_abs_path.as_ref() == Some(repo_abs_path) }) { repository.reload_buffer_diff_bases(cx); + cx.emit(RepositoryEvent::GitDirectoryChanged); } }); } From 4b7369481dcf36f22ff8f813d411e1d296aebe57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Raz=20Guzm=C3=A1n=20Macedo?= Date: Fri, 3 Jul 2026 16:05:34 -0600 Subject: [PATCH 067/197] Add dylint lint library for Zed-specific patterns (#58496) Adds a dylint library under tooling/lints that flags Zed-specific anti-patterns: * shared_string_from_str_literal, * async_block_without_await, * entity_update_in_render, * notify_in_render, * owned_string_into_shared, * len_in_loop_condition, and * blocking_io_on_foreground. Includes UI tests, a single-lint helper, and workspace.metadata.dylint registration so cargo dylint --all discovers it. The library pins its own nightly toolchain (kept out of the main workspace) and tracks dylint 6. Release Notes: - N/A 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 Closes #ISSUE Release Notes: - N/A or Added/Fixed/Improved ... --- .agents/skills/lint-creator/SKILL.md | 17 + Cargo.toml | 7 + tooling/lints/.cargo/config.toml | 2 + tooling/lints/.gitignore | 4 + tooling/lints/Cargo.toml | 29 + tooling/lints/README.md | 86 +++ tooling/lints/rust-toolchain.toml | 3 + tooling/lints/single-lint | 64 ++ .../lints/src/blocking_io_on_foreground.rs | 246 ++++++++ tooling/lints/src/entity_update_in_render.rs | 66 ++ tooling/lints/src/lib.rs | 565 ++++++++++++++++++ tooling/lints/src/notify_in_render.rs | 58 ++ tooling/lints/src/owned_string_into_shared.rs | 171 ++++++ tooling/lints/src/render_helpers.rs | 98 +++ tooling/lints/test_fixture/Cargo.toml | 6 + .../lints/test_fixture/consumer/Cargo.toml | 11 + .../lints/test_fixture/consumer/src/lib.rs | 63 ++ tooling/lints/test_fixture/gpui/Cargo.toml | 8 + tooling/lints/test_fixture/gpui/src/lib.rs | 100 ++++ .../gpui_shared_string/Cargo.toml | 8 + .../gpui_shared_string/src/lib.rs | 31 + .../test_fixture/render_consumer/Cargo.toml | 11 + .../test_fixture/render_consumer/src/lib.rs | 154 +++++ tooling/lints/ui/async_block_without_await.rs | 79 +++ .../lints/ui/async_block_without_await.stderr | 56 ++ tooling/lints/ui/blocking_io_on_foreground.rs | 324 ++++++++++ .../lints/ui/blocking_io_on_foreground.stderr | 395 ++++++++++++ tooling/lints/ui/entity_update_in_render.rs | 116 ++++ .../lints/ui/entity_update_in_render.stderr | 30 + tooling/lints/ui/owned_string_into_shared.rs | 68 +++ .../lints/ui/owned_string_into_shared.stderr | 53 ++ 31 files changed, 2929 insertions(+) create mode 100644 .agents/skills/lint-creator/SKILL.md create mode 100644 tooling/lints/.cargo/config.toml create mode 100644 tooling/lints/.gitignore create mode 100644 tooling/lints/Cargo.toml create mode 100644 tooling/lints/README.md create mode 100644 tooling/lints/rust-toolchain.toml create mode 100755 tooling/lints/single-lint create mode 100644 tooling/lints/src/blocking_io_on_foreground.rs create mode 100644 tooling/lints/src/entity_update_in_render.rs create mode 100644 tooling/lints/src/lib.rs create mode 100644 tooling/lints/src/notify_in_render.rs create mode 100644 tooling/lints/src/owned_string_into_shared.rs create mode 100644 tooling/lints/src/render_helpers.rs create mode 100644 tooling/lints/test_fixture/Cargo.toml create mode 100644 tooling/lints/test_fixture/consumer/Cargo.toml create mode 100644 tooling/lints/test_fixture/consumer/src/lib.rs create mode 100644 tooling/lints/test_fixture/gpui/Cargo.toml create mode 100644 tooling/lints/test_fixture/gpui/src/lib.rs create mode 100644 tooling/lints/test_fixture/gpui_shared_string/Cargo.toml create mode 100644 tooling/lints/test_fixture/gpui_shared_string/src/lib.rs create mode 100644 tooling/lints/test_fixture/render_consumer/Cargo.toml create mode 100644 tooling/lints/test_fixture/render_consumer/src/lib.rs create mode 100644 tooling/lints/ui/async_block_without_await.rs create mode 100644 tooling/lints/ui/async_block_without_await.stderr create mode 100644 tooling/lints/ui/blocking_io_on_foreground.rs create mode 100644 tooling/lints/ui/blocking_io_on_foreground.stderr create mode 100644 tooling/lints/ui/entity_update_in_render.rs create mode 100644 tooling/lints/ui/entity_update_in_render.stderr create mode 100644 tooling/lints/ui/owned_string_into_shared.rs create mode 100644 tooling/lints/ui/owned_string_into_shared.stderr diff --git a/.agents/skills/lint-creator/SKILL.md b/.agents/skills/lint-creator/SKILL.md new file mode 100644 index 00000000000..e72e83a9aba --- /dev/null +++ b/.agents/skills/lint-creator/SKILL.md @@ -0,0 +1,17 @@ +--- +name: lint-creator +description: An auxiliary skill to add more dylints to `tooling/lints` +disable-model-invocation: false +--- + +# Lint RULES + +1. Every lint MUST have accompanying `ui` tests +2. `ui` tests MUST be in the `ui` folder +3. Every lint MUST be in a separate module +4. Every lint MUST have negative `ui` tests +5. Lints should be as simple as possible. +6. Reporting is fine if it's simple, it does not need to be elaborate or lengthy code. +7. Do NOT suggest how to fix the lint, only flag it. +8. Do NOT make lints machine applicable. +9. Detect if lints are redundant vs clippy's capabilities. diff --git a/Cargo.toml b/Cargo.toml index 5e7703aaaee..2adf179e513 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1070,3 +1070,10 @@ ignored = [ "documented", "sea-orm-macros", ] + +# Dylint discovers our custom lints through this entry, so `cargo dylint --all` +# runs them without a `--path` argument. The `lints` package pins its own +# nightly toolchain (see `tooling/lints/rust-toolchain.toml`) and is kept out of +# this workspace on purpose. +[workspace.metadata.dylint] +libraries = [{ path = "tooling/lints" }] diff --git a/tooling/lints/.cargo/config.toml b/tooling/lints/.cargo/config.toml new file mode 100644 index 00000000000..93dceb69922 --- /dev/null +++ b/tooling/lints/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.'cfg(all())'] +linker = "dylint-link" diff --git a/tooling/lints/.gitignore b/tooling/lints/.gitignore new file mode 100644 index 00000000000..26b452057c5 --- /dev/null +++ b/tooling/lints/.gitignore @@ -0,0 +1,4 @@ +/target/ +/test_fixture/target/ +Cargo.lock +test_fixture/Cargo.lock diff --git a/tooling/lints/Cargo.toml b/tooling/lints/Cargo.toml new file mode 100644 index 00000000000..d70050bfa61 --- /dev/null +++ b/tooling/lints/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "lints" +version = "0.1.0" +edition = "2024" +publish = false +description = "Dylint lints for catching bad Zed specific patterns." + +[lib] +crate-type = ["cdylib"] + +[dependencies] +clippy_utils = { git = "https://github.com/rust-lang/rust-clippy", rev = "86390a3c03438b660c5efc64d4e18ae65982f5c0" } +dylint_linting = "6.0" + +[dev-dependencies] +# `deny_warnings` restores `-D warnings` for the UI fixtures (off by default +# since dylint 3.0), so toolchain drift surfaces as a failing test. +dylint_testing = { version = "6.0", features = ["deny_warnings"] } + +[package.metadata.rust-analyzer] +rustc_private = true + +# Keep this crate out of the zed workspace. It pins its own nightly toolchain +# (see `rust-toolchain.toml`) to match `clippy_utils`. +[workspace] + +[lints.rust.unexpected_cfgs] +level = "warn" +check-cfg = ["cfg(dylint_lib, values(any()))"] diff --git a/tooling/lints/README.md b/tooling/lints/README.md new file mode 100644 index 00000000000..6804a2e9093 --- /dev/null +++ b/tooling/lints/README.md @@ -0,0 +1,86 @@ +# lints + +A [dylint](https://github.com/trailofbits/dylint) library that flags various bad patterns in our codebase. + +Install `dylint`, a pinned nightly toolchain and the necessary tools with + +``` +cargo install cargo-dylint dylint-link +cd tooling/lints +rustup toolchain install +``` + +The channel and its components (`rustc-dev`, `rust-src`, `llvm-tools-preview`) +are declared in `tooling/lints/rust-toolchain.toml`, so `rustup toolchain install` +picks them up automatically when run from that directory. + +# Demo + +``` +./single-lint blocking_io_on_foreground +``` + + +## Current lints +- `shared_string_from_str_literal` — `SharedString::new/from` etc where `SharedString::from_static` should be used instead. +- `async_block_without_await` — `async { … }` blocks whose body contains no `.await` expression. +- `entity_update_in_render` — `Entity::update`/`WeakEntity::update` mutating an entity inside `Render::render`. +- `notify_in_render` — `Context::notify()` called inside `Render::render`. +- `owned_string_into_shared` — `String::from().into()` / `.to_string().into()` / `.to_owned().into()` whose target is `SharedString`, `Arc`, `Rc`, or `Cow<'_, str>`. +- `blocking_io_on_foreground` - Catch blocking IO calls that are called on the main thread (but not on closures or background threads) + +## How to run + +Ideally you run this as part of the `clippy` script in the `zed/scripts` directory since this will also run our other linters. + +### Prerequisites + +Install both tools (version 6 or later): + +``` +cargo install cargo-dylint dylint-link +``` + +- `cargo-dylint` is the `cargo` subcommand that builds and runs the lints; `dylint-link` is the linker used to build the lint library. + +The workspace registers this library under `[workspace.metadata.dylint]` in the +root `Cargo.toml`, so Dylint discovers it automatically — you do not pass a +`--path`. The first run builds the library against its pinned nightly (see +`rust-toolchain.toml`) and is slow; later runs are cached. + +### Run all lints against the whole repo + +``` +cargo dylint --all -- --workspace +``` + +### Run all lints against a single crate + +``` +cargo dylint --all -- -p project_panel +``` + +### Run a single lint + +The library loads every lint at once. To run just one, use the `single-lint` +helper, which silences the rest and force-enables the one you name: + +``` +tooling/lints/single-lint blocking_io_on_foreground -p project_panel +``` + +The first argument is the lint name (one of the snake_case identifiers under +[Current lints](#current-lints)); everything after it is passed to `cargo check` +and defaults to `--workspace`. Under the hood the script runs: + +``` +DYLINT_RUSTFLAGS="-A warnings --force-warn " cargo dylint --all -- +``` + +It also handles two non-obvious gotchas: + +- `--force-warn` is required: after `-A warnings` silences the group, a plain + `-W ` does not reliably re-enable a driver-registered lint. +- `DYLINT_RUSTFLAGS` is not part of Cargo's fingerprint, so the script cleans the + targeted package(s) first; otherwise Cargo replays a stale cache and the filter + appears to do nothing. diff --git a/tooling/lints/rust-toolchain.toml b/tooling/lints/rust-toolchain.toml new file mode 100644 index 00000000000..0ba86155420 --- /dev/null +++ b/tooling/lints/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "nightly-2026-03-21" +components = ["llvm-tools-preview", "rustc-dev", "rust-src"] diff --git a/tooling/lints/single-lint b/tooling/lints/single-lint new file mode 100755 index 00000000000..e481cf91bc6 --- /dev/null +++ b/tooling/lints/single-lint @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# +# Run a single lint from this dylint library against the Zed workspace. +# +# Usage: tooling/lints/single-lint [cargo check args...] +# Example: tooling/lints/single-lint blocking_io_on_foreground -p project_panel +# +# Dylint loads the whole library, so we silence every lint with `-A warnings` +# and force just the requested one back on with `--force-warn`. A plain +# `-W ` is dropped for a driver-registered lint once the group is allowed, +# which is why `--force-warn` is required. +# +# `DYLINT_RUSTFLAGS` is not part of Cargo's fingerprint, so changing it does not +# invalidate already-checked crates. We therefore clean the targeted package(s) +# first so the filter actually applies instead of replaying a stale cache. When +# no package is named (a `--workspace` run) we drop the whole check cache. +set -euo pipefail + +if [ "$#" -lt 1 ]; then + echo "usage: $(basename "$0") [cargo check args...]" >&2 + exit 1 +fi + +lint="$1" +shift +if [ "$#" -eq 0 ]; then + set -- --workspace +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" + +toolchain="$(awk -F'"' '/^channel/ {print $2}' "$script_dir/rust-toolchain.toml")" +host="$(rustc -vV | awk '/^host:/ {print $2}')" +check_target="$repo_root/target/dylint/target/${toolchain}-${host}" + +# Force a re-check of the requested package(s) so the lint filter takes effect. +cleaned_any=0 +prev="" +for arg in "$@"; do + pkg="" + case "$arg" in + -p=* | --package=*) + pkg="${arg#*=}" + ;; + *) + if [ "$prev" = "-p" ] || [ "$prev" = "--package" ]; then + pkg="$arg" + fi + ;; + esac + if [ -n "$pkg" ]; then + cargo clean -p "$pkg" --target-dir "$check_target" 2>/dev/null || true + cleaned_any=1 + fi + prev="$arg" +done + +if [ "$cleaned_any" -eq 0 ]; then + rm -rf "$check_target" +fi + +cd "$repo_root" +DYLINT_RUSTFLAGS="-A warnings --force-warn $lint" exec cargo dylint --all -- "$@" diff --git a/tooling/lints/src/blocking_io_on_foreground.rs b/tooling/lints/src/blocking_io_on_foreground.rs new file mode 100644 index 00000000000..d8d7fe16e91 --- /dev/null +++ b/tooling/lints/src/blocking_io_on_foreground.rs @@ -0,0 +1,246 @@ +use clippy_utils::diagnostics::span_lint; +use rustc_hir::def::Res; +use rustc_hir::{Expr, ExprKind, HirId, Node}; +use rustc_lint::{LateContext, LateLintPass}; +use rustc_middle::ty::Ty; + +use crate::render_helpers::is_directly_in_render_method; + +rustc_session::declare_lint! { + /// ### What it does + /// + /// Flags calls to known blocking IO functions from the standard library + /// (`std::fs`, `std::thread::sleep`, `std::process::Command`, `std::net`) + /// when they appear inside a function that receives a synchronous GPUI + /// context parameter (`&App`, `&mut App`, `&Context`, + /// `&mut Context`, `&mut Window`) or directly inside a + /// `Render::render` / `RenderOnce::render` method. + /// + /// ### Why is this bad? + /// + /// In GPUI, code that receives a synchronous context type runs on the + /// foreground (UI) thread. A blocking IO call on this thread freezes the + /// application until the syscall returns. + pub BLOCKING_IO_ON_FOREGROUND, + Warn, + "blocking IO call on the GPUI foreground thread" +} + +pub(crate) struct BlockingIoOnForeground; + +rustc_session::impl_lint_pass!(BlockingIoOnForeground => [BLOCKING_IO_ON_FOREGROUND]); + +const BLOCKING_FN_PATHS: &[&str] = &[ + // std::fs free functions + "std::fs::read", + "std::fs::read_to_string", + "std::fs::write", + "std::fs::read_dir", + "std::fs::read_link", + "std::fs::metadata", + "std::fs::symlink_metadata", + "std::fs::set_permissions", + "std::fs::canonicalize", + "std::fs::create_dir", + "std::fs::create_dir_all", + "std::fs::remove_file", + "std::fs::remove_dir", + "std::fs::remove_dir_all", + "std::fs::copy", + "std::fs::rename", + "std::fs::hard_link", + // std::fs::File associated functions + "std::fs::File::open", + "std::fs::File::create", + "std::fs::File::create_new", + // std::thread + "std::thread::sleep", + // std::path::Path methods (resolved via method call def_id) + "std::path::Path::metadata", + "std::path::Path::symlink_metadata", + "std::path::Path::read_link", + "std::path::Path::read_dir", + "std::path::Path::exists", + "std::path::Path::try_exists", + "std::path::Path::is_file", + "std::path::Path::is_dir", + "std::path::Path::is_symlink", + "std::path::Path::canonicalize", + // std::net associated functions + "std::net::TcpStream::connect", + "std::net::TcpStream::connect_timeout", + "std::net::TcpListener::bind", + "std::net::UdpSocket::bind", +]; + +const BLOCKING_METHODS: &[(&str, &str)] = &[ + // std::process + ("Command", "output"), + ("Command", "status"), + ("Command", "spawn"), + ("Child", "wait"), + ("Child", "wait_with_output"), + // std::fs::File instance methods + ("File", "sync_all"), + ("File", "sync_data"), + ("File", "set_len"), + ("File", "metadata"), + ("File", "try_clone"), + ("File", "set_permissions"), + // std::net — TCP + ("TcpStream", "connect"), + ("TcpStream", "peek"), + ("TcpListener", "bind"), + ("TcpListener", "accept"), + ("TcpListener", "incoming"), + // std::net — UDP + ("UdpSocket", "send"), + ("UdpSocket", "send_to"), + ("UdpSocket", "recv"), + ("UdpSocket", "recv_from"), + ("UdpSocket", "peek"), + ("UdpSocket", "peek_from"), + // std::sync + ("Mutex", "lock"), + ("RwLock", "read"), + ("RwLock", "write"), + ("Condvar", "wait"), + ("Condvar", "wait_timeout"), + ("Condvar", "wait_while"), + ("Barrier", "wait"), + // std::sync::mpsc + ("Receiver", "recv"), + ("Receiver", "recv_timeout"), + ("SyncSender", "send"), +]; + +fn is_blocking_call(cx: &LateContext<'_>, expr: &Expr<'_>) -> bool { + match &expr.kind { + ExprKind::Call(callee, _) => { + if let ExprKind::Path(qpath) = &callee.kind { + if let Res::Def(_, def_id) = cx.qpath_res(qpath, callee.hir_id) { + let path = cx.tcx.def_path_str(def_id); + return BLOCKING_FN_PATHS.iter().any(|blocked| path == *blocked); + } + } + false + } + ExprKind::MethodCall(segment, receiver, _args, _span) => { + if let Some(def_id) = cx.typeck_results().type_dependent_def_id(expr.hir_id) { + let path = cx.tcx.def_path_str(def_id); + if BLOCKING_FN_PATHS.iter().any(|blocked| path == *blocked) { + return true; + } + } + let method_name = segment.ident.name.as_str(); + if !BLOCKING_METHODS + .iter() + .any(|(_, name)| *name == method_name) + { + return false; + } + let receiver_ty = cx.typeck_results().expr_ty(receiver).peel_refs(); + if let Some(adt) = receiver_ty.ty_adt_def() { + let type_name = cx.tcx.item_name(adt.did()); + return BLOCKING_METHODS + .iter() + .any(|(ty, name)| *name == method_name && type_name.as_str() == *ty); + } + false + } + _ => false, + } +} + +/// Returns `true` if `ty` (after peeling references) is a synchronous GPUI +/// foreground type: `App`, `Context`, or `Window`. +fn is_gpui_foreground_type<'tcx>(cx: &LateContext<'tcx>, ty: Ty<'tcx>) -> bool { + let peeled = ty.peel_refs(); + let Some(adt) = peeled.ty_adt_def() else { + return false; + }; + let did = adt.did(); + let crate_name = cx.tcx.crate_name(did.krate); + if crate_name.as_str() != "gpui" { + return false; + } + let name = cx.tcx.item_name(did); + matches!(name.as_str(), "App" | "Context" | "Window") +} + +/// Walks up the HIR parent chain from `hir_id` to find the enclosing +/// function. Returns `true` if that function has a parameter whose type is a +/// synchronous GPUI context type. Returns `false` if a closure boundary is +/// crossed first (the closure might run on a background thread). +fn is_in_foreground_fn(cx: &LateContext<'_>, hir_id: HirId) -> bool { + for (_parent_id, node) in cx.tcx.hir_parent_iter(hir_id) { + match node { + Node::Expr(expr) if matches!(expr.kind, ExprKind::Closure(_)) => { + return false; + } + Node::Item(item) => { + if let rustc_hir::ItemKind::Fn { .. } = &item.kind { + let owner_id = item.owner_id.def_id; + return owner_has_foreground_param(cx, owner_id); + } + return false; + } + Node::ImplItem(impl_item) => { + if let rustc_hir::ImplItemKind::Fn(_, _) = &impl_item.kind { + let owner_id = impl_item.owner_id.def_id; + return owner_has_foreground_param(cx, owner_id); + } + return false; + } + Node::TraitItem(trait_item) => { + if let rustc_hir::TraitItemKind::Fn(_, _) = &trait_item.kind { + let owner_id = trait_item.owner_id.def_id; + return owner_has_foreground_param(cx, owner_id); + } + return false; + } + _ => {} + } + } + false +} + +/// Checks whether the function identified by `local_def_id` has any parameter +/// whose type is a synchronous GPUI foreground type. +fn owner_has_foreground_param( + cx: &LateContext<'_>, + local_def_id: rustc_hir::def_id::LocalDefId, +) -> bool { + let def_id = local_def_id.to_def_id(); + let sig = cx.tcx.fn_sig(def_id).instantiate_identity(); + sig.inputs() + .skip_binder() + .iter() + .any(|ty| is_gpui_foreground_type(cx, *ty)) +} + +impl<'tcx> LateLintPass<'tcx> for BlockingIoOnForeground { + fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { + if expr.span.from_expansion() { + return; + } + + if !is_blocking_call(cx, expr) { + return; + } + + let in_render = is_directly_in_render_method(cx, expr.hir_id); + let in_foreground_fn = is_in_foreground_fn(cx, expr.hir_id); + + if !in_render && !in_foreground_fn { + return; + } + + span_lint( + cx, + BLOCKING_IO_ON_FOREGROUND, + expr.span, + "blocking IO call on the GPUI foreground thread", + ); + } +} diff --git a/tooling/lints/src/entity_update_in_render.rs b/tooling/lints/src/entity_update_in_render.rs new file mode 100644 index 00000000000..fc17f3de2be --- /dev/null +++ b/tooling/lints/src/entity_update_in_render.rs @@ -0,0 +1,66 @@ +use clippy_utils::diagnostics::span_lint; +use rustc_hir::{Expr, ExprKind}; +use rustc_lint::{LateContext, LateLintPass}; + +use crate::render_helpers::{ + is_directly_in_render_method, is_gpui_entity_or_weak, is_unit_or_result_unit, +}; + +rustc_session::declare_lint! { + /// ### What it does + /// + /// Flags calls to `Entity::update` or `WeakEntity::update` that execute + /// synchronously inside a `Render::render` or `RenderOnce::render` method + /// and whose closure returns `()` (indicating mutation rather than reading). + /// + /// ### Why is this bad? + /// + /// The `render` method should be a pure function of state. Calling + /// `.update()` mutates an entity during the render pass, which can trigger + /// re-renders mid-render and lead to inconsistent UI state or infinite + /// render loops. + pub ENTITY_UPDATE_IN_RENDER, + Warn, + "mutating an entity via `.update()` during render" +} + +pub(crate) struct EntityUpdateInRender; + +rustc_session::impl_lint_pass!(EntityUpdateInRender => [ENTITY_UPDATE_IN_RENDER]); + +impl<'tcx> LateLintPass<'tcx> for EntityUpdateInRender { + fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { + if expr.span.from_expansion() { + return; + } + + let ExprKind::MethodCall(segment, receiver, _args, _span) = &expr.kind else { + return; + }; + + if segment.ident.name.as_str() != "update" { + return; + } + + let receiver_ty = cx.typeck_results().expr_ty(receiver); + if !is_gpui_entity_or_weak(cx, receiver_ty) { + return; + } + + let call_ty = cx.typeck_results().expr_ty(expr); + if !is_unit_or_result_unit(cx, call_ty) { + return; + } + + if !is_directly_in_render_method(cx, expr.hir_id) { + return; + } + + span_lint( + cx, + ENTITY_UPDATE_IN_RENDER, + expr.span, + "entity `.update()` called during render mutates state in the render pass", + ); + } +} diff --git a/tooling/lints/src/lib.rs b/tooling/lints/src/lib.rs new file mode 100644 index 00000000000..bf4289673dd --- /dev/null +++ b/tooling/lints/src/lib.rs @@ -0,0 +1,565 @@ +#![feature(rustc_private)] +#![warn(unused_extern_crates)] + +extern crate rustc_ast; +extern crate rustc_errors; +extern crate rustc_hir; +extern crate rustc_lint; +extern crate rustc_middle; +extern crate rustc_session; +extern crate rustc_span; + +use clippy_utils::diagnostics::{span_lint_and_help, span_lint_and_then}; +use clippy_utils::is_def_id_trait_method; +use clippy_utils::source::snippet_opt; +use rustc_ast::ast::LitKind; +use rustc_errors::Applicability; +use rustc_hir::def::{DefKind, Res}; +use rustc_hir::def_id::DefId; +use rustc_hir::intravisit::{Visitor, walk_expr}; +use rustc_hir::{ + Closure, ClosureKind, CoroutineDesugaring, CoroutineKind, CoroutineSource, Expr, ExprKind, + YieldSource, +}; +use rustc_lint::{LateContext, LateLintPass}; +use rustc_middle::hir::nested_filter; +use rustc_middle::ty::Ty; +use rustc_span::Span; + +mod blocking_io_on_foreground; +mod entity_update_in_render; +mod notify_in_render; +mod owned_string_into_shared; +mod render_helpers; + +use blocking_io_on_foreground::BLOCKING_IO_ON_FOREGROUND; +use entity_update_in_render::ENTITY_UPDATE_IN_RENDER; +use notify_in_render::NOTIFY_IN_RENDER; +use owned_string_into_shared::OWNED_STRING_INTO_SHARED; + +// --------------------------------------------------------------------------- +// Boilerplate: export the dylint ABI version symbol. +// --------------------------------------------------------------------------- +dylint_linting::dylint_library!(); + +// --------------------------------------------------------------------------- +// Registration: a single entry point that hands both lints to the compiler. +// --------------------------------------------------------------------------- +#[allow(clippy::no_mangle_with_rust_abi)] +#[unsafe(no_mangle)] +pub fn register_lints(sess: &rustc_session::Session, lint_store: &mut rustc_lint::LintStore) { + dylint_linting::init_config(sess); + lint_store.register_lints(&[ + SHARED_STRING_FROM_STR_LITERAL, + ASYNC_BLOCK_WITHOUT_AWAIT, + BLOCKING_IO_ON_FOREGROUND, + ENTITY_UPDATE_IN_RENDER, + NOTIFY_IN_RENDER, + OWNED_STRING_INTO_SHARED, + ]); + lint_store.register_late_pass(|_| Box::new(SharedStringFromStrLiteral)); + lint_store.register_late_pass(|_| Box::new(AsyncBlockWithoutAwait)); + lint_store.register_late_pass(|_| Box::new(blocking_io_on_foreground::BlockingIoOnForeground)); + lint_store.register_late_pass(|_| Box::new(entity_update_in_render::EntityUpdateInRender)); + lint_store.register_late_pass(|_| Box::new(notify_in_render::NotifyInRender)); + lint_store.register_late_pass(|_| Box::new(owned_string_into_shared::OwnedStringIntoShared)); +} + +// =========================================================================== +// Lint A — SHARED_STRING_FROM_STR_LITERAL +// =========================================================================== + +rustc_session::declare_lint! { + /// ### What it does + /// + /// Flags `gpui::SharedString` values constructed from a string literal by + /// any path other than `SharedString::new_static`. + /// + /// ### Why is this bad? + /// + /// `SharedString` wraps a `SmolStr`. `SmolStr::from` either copies the + /// bytes into inline storage (literals ≤ 23 bytes) or allocates a fresh + /// `Arc` on the heap (literals > 23 bytes). `SharedString::new_static` + /// does neither: it stores the `'static` pointer directly. For a string + /// literal the constant-pointer path is always available and strictly + /// cheaper. + /// + /// This lint fires on `SharedString::from("…")`, `SharedString::new("…")`, + /// `>::from("…")`, and `"…".into()` whose inferred + /// target type is `SharedString`. It does not fire on + /// `SharedString::new_static(…)`. + /// + /// The lint distinguishes two tiers of wastefulness: + /// * Literals > 23 bytes trigger a heap allocation per call site, flagged + /// at full severity. + /// * Literals ≤ 23 bytes "only" pay a memcpy; still strictly worse than + /// `new_static`, but cheaper to leave alone. + /// + /// ### Example + /// + /// ```ignore + /// let s: SharedString = SharedString::from("Right-click for more options"); + /// let t: SharedString = "hello".into(); + /// ``` + /// + /// Use instead: + /// + /// ```ignore + /// let s = SharedString::new_static("Right-click for more options"); + /// let t = SharedString::new_static("hello"); + /// ``` + pub SHARED_STRING_FROM_STR_LITERAL, + Warn, + "constructing a `SharedString` from a string literal via a copying/allocating path" +} + +rustc_session::declare_lint_pass!(SharedStringFromStrLiteral => [SHARED_STRING_FROM_STR_LITERAL]); + +/// Maximum number of bytes that `SmolStr` (and therefore `SharedString`) can +/// store inline on 64-bit targets. Literals larger than this trigger an +/// `Arc` allocation on every conversion. +/// +/// Source: `smol_str` v0.3's `INLINE_CAP`. See +/// . +const SMOL_STR_INLINE_CAP: usize = 23; + +impl<'tcx> LateLintPass<'tcx> for SharedStringFromStrLiteral { + fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { + // Do not descend into macro-expanded code: we'd be suggesting edits to + // spans the user cannot actually touch. + if expr.span.from_expansion() { + return; + } + + let ty = cx.typeck_results().expr_ty(expr); + if !is_shared_string(cx, ty) { + return; + } + + let Some(literal) = extract_literal_source(cx, expr) else { + return; + }; + + emit_shared_string(cx, expr.span, literal); + } +} + +/// A string literal the user wrote and that we are confident we can replace. +struct LiteralSource { + /// The literal's decoded contents. + contents: String, + /// The span of the full expression that should be replaced (e.g. the whole + /// `SharedString::from("x")` call), not just the literal token. + replace_span: Span, +} + +fn extract_literal_source<'tcx>( + cx: &LateContext<'tcx>, + expr: &'tcx Expr<'tcx>, +) -> Option { + match &expr.kind { + // `SharedString::from(lit)`, `SharedString::new(lit)`, or any other + // associated/trait function resolving onto `SharedString` with a + // single string-literal argument. + ExprKind::Call(func, [arg]) => { + let def_id = call_def_id(cx, func)?; + if !is_interesting_shared_string_constructor(cx, def_id) { + return None; + } + let contents = str_literal_contents(arg)?; + Some(LiteralSource { + contents, + replace_span: expr.span, + }) + } + + // `lit.into()` where the target type is `SharedString`. + ExprKind::MethodCall(path_seg, receiver, [], _) + if path_seg.ident.name.as_str() == "into" => + { + let contents = str_literal_contents(receiver)?; + Some(LiteralSource { + contents, + replace_span: expr.span, + }) + } + + _ => None, + } +} + +/// Extract the contents of a string literal expression, peeling through a +/// single layer of reference if the user wrote `&"lit"`. +fn str_literal_contents<'tcx>(expr: &'tcx Expr<'tcx>) -> Option { + let inner = match &expr.kind { + ExprKind::AddrOf(_, _, inner) => *inner, + _ => expr, + }; + if let ExprKind::Lit(lit) = &inner.kind + && let LitKind::Str(sym, _) = lit.node + { + Some(sym.as_str().to_owned()) + } else { + None + } +} + +/// Returns the `DefId` of the function being called, if `func` is a direct +/// path to a function or associated function. This handles both +/// `Type::method(...)` syntax (including type-relative paths that resolve +/// through `typeck_results`) and free-function paths. +fn call_def_id<'tcx>(cx: &LateContext<'tcx>, func: &'tcx Expr<'tcx>) -> Option { + let ExprKind::Path(qpath) = &func.kind else { + return None; + }; + match cx.qpath_res(qpath, func.hir_id) { + Res::Def(DefKind::Fn | DefKind::AssocFn, def_id) => Some(def_id), + _ => None, + } +} + +/// True if `def_id` names a `SharedString` constructor that we treat as a +/// wasteful alternative to `SharedString::new_static` when passed a string +/// literal. +/// +/// This covers two distinct resolutions rustc produces for these call sites: +/// +/// * `SharedString::new("x")` resolves to the inherent associated function +/// on `impl SharedString`. The impl's `Self` type is `SharedString`. +/// * `SharedString::from("x")` resolves to the trait method +/// `core::convert::From::from`. The impl is not recorded on the `def_id` +/// itself; we instead verify the enclosing trait is `From` and rely on the +/// caller having already checked that the call's result type is +/// `SharedString`. +/// +/// `SharedString::new_static` is explicitly exempted because it is the +/// preferred alternative. +fn is_interesting_shared_string_constructor(cx: &LateContext<'_>, def_id: DefId) -> bool { + let tcx = cx.tcx; + let name = tcx.item_name(def_id); + if name.as_str() == "new_static" { + return false; + } + if !matches!(name.as_str(), "from" | "new") { + return false; + } + if let Some(impl_id) = tcx.impl_of_assoc(def_id) { + let self_ty = tcx.type_of(impl_id).skip_binder(); + return is_shared_string(cx, self_ty); + } + if let Some(trait_id) = tcx.trait_of_assoc(def_id) { + // The caller has already asserted that the call's result type is + // `SharedString`, so a `From::from` call resolving here is + // equivalent to `>::from`. + let path = tcx.def_path_str(trait_id); + return path == "core::convert::From" || path == "std::convert::From"; + } + false +} + +/// Match the canonical definition path of `gpui_shared_string::SharedString`. +/// Re-exports through `gpui` resolve back to the same `DefId`. +fn is_shared_string(cx: &LateContext<'_>, ty: Ty<'_>) -> bool { + let Some(adt) = ty.ty_adt_def() else { + return false; + }; + let did = adt.did(); + let krate = cx.tcx.crate_name(did.krate); + if krate.as_str() != "gpui_shared_string" { + return false; + } + cx.tcx.item_name(did).as_str() == "SharedString" +} + +fn emit_shared_string(cx: &LateContext<'_>, call_span: Span, literal: LiteralSource) { + let LiteralSource { + contents, + replace_span, + } = literal; + let byte_len = contents.len(); + let over_inline = byte_len > SMOL_STR_INLINE_CAP; + + // Use the original source text for the replacement where possible so we + // preserve raw-string syntax, escapes, etc. Fall back to debug-formatting + // the decoded contents if the source is unavailable (e.g. macro-generated). + let replacement_lit = snippet_opt(cx, replace_span) + .and_then(extract_embedded_string_literal) + .unwrap_or_else(|| format!("{contents:?}")); + + let suggestion = format!("SharedString::new_static({replacement_lit})"); + + let primary_msg = if over_inline { + "this `SharedString` construction heap-allocates on every call" + } else { + "this `SharedString` construction copies the literal on every call" + }; + + span_lint_and_then( + cx, + SHARED_STRING_FROM_STR_LITERAL, + call_span, + primary_msg, + |diag| { + if over_inline { + diag.note(format!( + "the literal is {byte_len} bytes, which exceeds `SmolStr`'s {SMOL_STR_INLINE_CAP}-byte inline capacity, so `SmolStr::from` allocates an `Arc` here", + )); + } else { + diag.note(format!( + "the literal is {byte_len} bytes (≤ {SMOL_STR_INLINE_CAP}) so it stays inline, but the copy is still avoidable", + )); + } + diag.note("`SharedString::new_static` stores the `'static` pointer directly and performs no allocation or copy"); + diag.span_suggestion( + replace_span, + "use the zero-cost static constructor", + suggestion, + Applicability::MachineApplicable, + ); + }, + ); +} + +/// Given a snippet like `SharedString::from("hi")` or `"hi".into()`, extract +/// the first embedded string literal token (including any `r#"..."#` prefix) +/// so we can paste it back unchanged. This is a best-effort scanner and +/// returns `None` when the snippet has no literal or an unterminated one. +fn extract_embedded_string_literal(snippet: String) -> Option { + let bytes = snippet.as_bytes(); + let mut i = 0; + while i < bytes.len() { + // Raw string: optional `b`, then `r`, then `#`*, then `"`. + let raw_start = i; + let mut j = i; + if j < bytes.len() && bytes[j] == b'b' { + j += 1; + } + if j < bytes.len() && bytes[j] == b'r' { + let mut hashes = 0; + let mut k = j + 1; + while k < bytes.len() && bytes[k] == b'#' { + hashes += 1; + k += 1; + } + if k < bytes.len() && bytes[k] == b'"' { + // Scan for closing `"` followed by the same number of `#`. + let mut m = k + 1; + while m < bytes.len() { + if bytes[m] == b'"' { + let mut close_hashes = 0; + let mut n = m + 1; + while close_hashes < hashes && n < bytes.len() && bytes[n] == b'#' { + close_hashes += 1; + n += 1; + } + if close_hashes == hashes { + return Some(snippet[raw_start..n].to_owned()); + } + } + m += 1; + } + return None; + } + } + // Regular string: optional `b`, then `"`, until matching unescaped `"`. + let mut j = i; + if j < bytes.len() && bytes[j] == b'b' { + j += 1; + } + if j < bytes.len() && bytes[j] == b'"' { + let mut m = j + 1; + while m < bytes.len() { + match bytes[m] { + b'\\' => { + m += 2; + continue; + } + b'"' => return Some(snippet[raw_start..=m].to_owned()), + _ => m += 1, + } + } + return None; + } + i += 1; + } + None +} + +// =========================================================================== +// Lint B — ASYNC_BLOCK_WITHOUT_AWAIT +// =========================================================================== + +rustc_session::declare_lint! { + /// ### What it does + /// + /// Flags `async { … }` and `async move { … }` blocks whose body contains + /// no `.await` expression at their own nesting level. + /// + /// ### Why is this bad? + /// + /// An async block without an `.await` wraps synchronous code in a `Future` + /// state machine for no benefit. The state machine adds binary size, and + /// the indirection may hide the fact that the code never actually yields. + /// Either the `async` should be removed (the code is synchronous), or a + /// missing `.await` is a bug. + /// + /// ### Example + /// + /// ```ignore + /// let future = async { compute_something() }; + /// ``` + /// + /// Use instead: + /// + /// ```ignore + /// let value = compute_something(); + /// ``` + pub ASYNC_BLOCK_WITHOUT_AWAIT, + Warn, + "`async` block that contains no `.await` expression" +} + +rustc_session::declare_lint_pass!(AsyncBlockWithoutAwait => [ASYNC_BLOCK_WITHOUT_AWAIT]); + +impl<'tcx> LateLintPass<'tcx> for AsyncBlockWithoutAwait { + fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { + if expr.span.from_expansion() { + return; + } + + // Match only async blocks — not async function bodies or async closures. + let ExprKind::Closure(Closure { + kind: + ClosureKind::Coroutine(CoroutineKind::Desugared( + CoroutineDesugaring::Async, + CoroutineSource::Block, + )), + body, + .. + }) = &expr.kind + else { + return; + }; + + // Trait impls are constrained by the trait's signature. If the trait + // requires a method that returns a future, the implementor must produce + // an async block even when their implementation has nothing to await. + let enclosing_body_owner = cx.tcx.hir_enclosing_body_owner(expr.hir_id); + if is_def_id_trait_method(cx, enclosing_body_owner) { + return; + } + + let body = cx.tcx.hir_body(*body); + let mut visitor = AwaitVisitor { + cx, + found_await: false, + async_depth: 0, + }; + walk_expr(&mut visitor, body.value); + + if !visitor.found_await { + span_lint_and_help( + cx, + ASYNC_BLOCK_WITHOUT_AWAIT, + expr.span, + "this `async` block contains no `.await`", + None, + "consider removing the `async` block or adding the missing `.await`", + ); + } + } +} + +/// Walks the body of an async block looking for `.await` expressions. Tracks +/// nesting depth so that an `.await` inside a *nested* async block is not +/// attributed to the *outer* block. +struct AwaitVisitor<'a, 'tcx> { + cx: &'a LateContext<'tcx>, + found_await: bool, + async_depth: usize, +} + +impl<'tcx> Visitor<'tcx> for AwaitVisitor<'_, 'tcx> { + type NestedFilter = nested_filter::OnlyBodies; + + fn maybe_tcx(&mut self) -> Self::MaybeTyCtxt { + self.cx.tcx + } + + fn visit_expr(&mut self, expr: &'tcx Expr<'tcx>) { + if let ExprKind::Yield(_, YieldSource::Await { .. }) = expr.kind { + if self.async_depth == 0 { + self.found_await = true; + return; + } + } + + let is_nested_async_block = matches!( + expr.kind, + ExprKind::Closure(Closure { + kind: ClosureKind::Coroutine(CoroutineKind::Desugared( + CoroutineDesugaring::Async, + _ + )), + .. + }) + ); + + if is_nested_async_block { + self.async_depth += 1; + } + + walk_expr(self, expr); + + if is_nested_async_block { + self.async_depth -= 1; + } + } +} + +// =========================================================================== +// Tests +// =========================================================================== + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::process::Command; + + /// Build the test-fixture `gpui` crate and return rustc flags that make + /// it available to standalone UI test files via `extern crate gpui`. + fn gpui_fixture_rustc_flags() -> Vec { + let fixture_dir: PathBuf = [env!("CARGO_MANIFEST_DIR"), "test_fixture"] + .iter() + .collect(); + + let status = Command::new("cargo") + .args(["build", "--package", "gpui"]) + .current_dir(&fixture_dir) + .status() + .expect("failed to run cargo build for gpui fixture"); + assert!(status.success(), "gpui fixture build failed"); + + let rlib: PathBuf = fixture_dir.join("target/debug/libgpui.rlib"); + let deps: PathBuf = fixture_dir.join("target/debug/deps"); + + vec![ + "--edition=2021".to_string(), + format!("--extern=gpui={}", rlib.display()), + format!("-Ldependency={}", deps.display()), + ] + } + + #[test] + fn ui() { + let flags = gpui_fixture_rustc_flags(); + dylint_testing::ui::Test::src_base(env!("CARGO_PKG_NAME"), "ui") + .rustc_flags(flags) + .run(); + } + + #[test] + fn ui_shared_string() { + dylint_testing::ui_test_examples(env!("CARGO_PKG_NAME")); + } +} diff --git a/tooling/lints/src/notify_in_render.rs b/tooling/lints/src/notify_in_render.rs new file mode 100644 index 00000000000..edc5fb26f4d --- /dev/null +++ b/tooling/lints/src/notify_in_render.rs @@ -0,0 +1,58 @@ +use clippy_utils::diagnostics::span_lint; +use rustc_hir::{Expr, ExprKind}; +use rustc_lint::{LateContext, LateLintPass}; + +use crate::render_helpers::{is_directly_in_render_method, is_gpui_context}; + +rustc_session::declare_lint! { + /// ### What it does + /// + /// Flags calls to `Context::notify()` that execute synchronously inside a + /// `Render::render` method. + /// + /// ### Why is this bad? + /// + /// `notify()` tells the framework that the entity's state has changed and + /// it should be re-rendered. Calling it during render means every render + /// pass schedules another render pass — either an infinite loop or wasted + /// work. + pub NOTIFY_IN_RENDER, + Warn, + "calling `cx.notify()` during render schedules a redundant re-render" +} + +pub(crate) struct NotifyInRender; + +rustc_session::impl_lint_pass!(NotifyInRender => [NOTIFY_IN_RENDER]); + +impl<'tcx> LateLintPass<'tcx> for NotifyInRender { + fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { + if expr.span.from_expansion() { + return; + } + + let ExprKind::MethodCall(segment, receiver, _args, _span) = &expr.kind else { + return; + }; + + if segment.ident.name.as_str() != "notify" { + return; + } + + let receiver_ty = cx.typeck_results().expr_ty(receiver); + if !is_gpui_context(cx, receiver_ty) { + return; + } + + if !is_directly_in_render_method(cx, expr.hir_id) { + return; + } + + span_lint( + cx, + NOTIFY_IN_RENDER, + expr.span, + "`cx.notify()` called during render schedules a re-render every render pass", + ); + } +} diff --git a/tooling/lints/src/owned_string_into_shared.rs b/tooling/lints/src/owned_string_into_shared.rs new file mode 100644 index 00000000000..a47f2f43432 --- /dev/null +++ b/tooling/lints/src/owned_string_into_shared.rs @@ -0,0 +1,171 @@ +use clippy_utils::diagnostics::span_lint; +use rustc_ast::ast::LitKind; +use rustc_hir::def::{DefKind, Res}; +use rustc_hir::{Expr, ExprKind}; +use rustc_lint::{LateContext, LateLintPass}; +use rustc_middle::ty::Ty; + +rustc_session::declare_lint! { + /// ### What it does + /// + /// Flags expressions that build an owned `String` from a string literal + /// and then immediately convert it with `.into()` into one of the + /// refcounted/shared string types: `gpui::SharedString`, `Arc`, + /// `Rc`, or `Cow<'_, str>`. + /// + /// The flagged shapes are: + /// + /// ```ignore + /// let label: SharedString = String::from("foo").into(); + /// let key: Arc = "foo".to_string().into(); + /// let value: Rc = "foo".to_owned().into(); + /// ``` + /// + /// ### Why is this bad? + /// + /// Two heap allocations and two copies of the literal happen where one is + /// enough: `String::from` (or `to_string`/`to_owned`) allocates a `String` + /// and copies the bytes; the `.into()` conversion into the refcounted + /// destination then allocates an `Arc`-like buffer and copies the + /// bytes a second time. For string literals the destination can be built + /// directly from `'static` data with no allocation at all. + pub OWNED_STRING_INTO_SHARED, + Warn, + "an owned `String` is built from a string literal only to be converted into a refcounted string" +} + +pub(crate) struct OwnedStringIntoShared; + +rustc_session::impl_lint_pass!(OwnedStringIntoShared => [OWNED_STRING_INTO_SHARED]); + +impl<'tcx> LateLintPass<'tcx> for OwnedStringIntoShared { + fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { + if expr.span.from_expansion() { + return; + } + + let ExprKind::MethodCall(segment, receiver, [], _) = &expr.kind else { + return; + }; + if segment.ident.name.as_str() != "into" { + return; + } + + let dest_ty = cx.typeck_results().expr_ty(expr); + if !is_refcounted_string_destination(cx, dest_ty) { + return; + } + + // The receiver must produce an owned `String`. Confirming this rules + // out custom `into` impls on unrelated types that just happen to look + // similar. + let receiver_ty = cx.typeck_results().expr_ty(receiver); + if !is_std_string(cx, receiver_ty) { + return; + } + + if !is_owned_string_built_from_literal(cx, receiver) { + return; + } + + span_lint( + cx, + OWNED_STRING_INTO_SHARED, + expr.span, + "this allocates an owned `String` from a string literal only to convert it into a refcounted string", + ); + } +} + +/// Returns `true` when `ty` is one of the refcounted/shared string types this +/// lint targets: `gpui::SharedString`, `Arc`, `Rc`, or +/// `Cow<'_, str>`. +fn is_refcounted_string_destination<'tcx>(cx: &LateContext<'tcx>, ty: Ty<'tcx>) -> bool { + let Some(adt) = ty.ty_adt_def() else { + return false; + }; + let did = adt.did(); + + if cx.tcx.crate_name(did.krate).as_str() == "gpui_shared_string" + && cx.tcx.item_name(did).as_str() == "SharedString" + { + return true; + } + + let path = cx.tcx.def_path_str(did); + let is_str_wrapper = matches!( + path.as_str(), + "alloc::sync::Arc" + | "std::sync::Arc" + | "alloc::rc::Rc" + | "std::rc::Rc" + | "alloc::borrow::Cow" + | "std::borrow::Cow" + ); + if !is_str_wrapper { + return false; + } + + let rustc_middle::ty::TyKind::Adt(_, args) = ty.kind() else { + return false; + }; + args.iter() + .find_map(|arg| arg.as_type()) + .is_some_and(|inner| inner.is_str()) +} + +/// Returns `true` when `ty` is `alloc::string::String`. +fn is_std_string(cx: &LateContext<'_>, ty: Ty<'_>) -> bool { + let Some(adt) = ty.ty_adt_def() else { + return false; + }; + let path = cx.tcx.def_path_str(adt.did()); + path == "alloc::string::String" || path == "std::string::String" +} + +/// Returns `true` if `expr` matches one of: +/// +/// * `String::from()` +/// * `.to_string()` +/// * `.to_owned()` +fn is_owned_string_built_from_literal<'tcx>( + cx: &LateContext<'tcx>, + expr: &'tcx Expr<'tcx>, +) -> bool { + match &expr.kind { + ExprKind::Call(func, [arg]) => { + let ExprKind::Path(qpath) = &func.kind else { + return false; + }; + let Res::Def(DefKind::Fn | DefKind::AssocFn, def_id) = cx.qpath_res(qpath, func.hir_id) + else { + return false; + }; + if cx.tcx.item_name(def_id).as_str() != "from" { + return false; + } + is_string_literal(arg) + } + ExprKind::MethodCall(segment, receiver, [], _) => { + let name = segment.ident.name.as_str(); + if name != "to_string" && name != "to_owned" { + return false; + } + is_string_literal(receiver) + } + _ => false, + } +} + +/// Returns `true` if `expr` is a string-literal expression, optionally wrapped +/// in a single layer of reference (`&"lit"`). +fn is_string_literal<'tcx>(expr: &'tcx Expr<'tcx>) -> bool { + let inner = match &expr.kind { + ExprKind::AddrOf(_, _, inner) => *inner, + _ => expr, + }; + matches!( + &inner.kind, + ExprKind::Lit(lit) if matches!(lit.node, LitKind::Str(..)) + ) +} diff --git a/tooling/lints/src/render_helpers.rs b/tooling/lints/src/render_helpers.rs new file mode 100644 index 00000000000..f85adacc0cc --- /dev/null +++ b/tooling/lints/src/render_helpers.rs @@ -0,0 +1,98 @@ +use rustc_hir::def_id::DefId; +use rustc_hir::{ExprKind, HirId, Node}; +use rustc_lint::LateContext; +use rustc_middle::ty::Ty; + +/// Returns `true` when `hir_id` sits directly inside a `fn render` that +/// implements `gpui::Render` or `gpui::RenderOnce`, without an intervening +/// closure. If a closure sits between `hir_id` and the `render` method, the +/// expression executes later (e.g. in an event handler) and is not flagged. +pub(crate) fn is_directly_in_render_method(cx: &LateContext<'_>, hir_id: HirId) -> bool { + for (parent_id, node) in cx.tcx.hir_parent_iter(hir_id) { + match node { + Node::Expr(expr) if matches!(expr.kind, ExprKind::Closure(_)) => { + return false; + } + Node::ImplItem(impl_item) if impl_item.ident.name.as_str() == "render" => { + return is_render_trait_impl(cx, parent_id); + } + _ => {} + } + } + false +} + +/// Returns `true` when the `impl` block that owns `impl_item_hir_id` is an +/// implementation of `gpui::Render` or `gpui::RenderOnce`. +fn is_render_trait_impl(cx: &LateContext<'_>, impl_item_hir_id: HirId) -> bool { + let parent_owner = cx.tcx.hir_get_parent_item(impl_item_hir_id); + let node = cx.tcx.hir_node(parent_owner.into()); + if let Node::Item(item) = node { + if let rustc_hir::ItemKind::Impl(impl_block) = &item.kind { + if let Some(trait_ref) = &impl_block.of_trait { + if let rustc_hir::def::Res::Def(_, trait_def_id) = trait_ref.trait_ref.path.res { + return is_gpui_render_trait(cx, trait_def_id); + } + } + } + } + false +} + +fn is_gpui_render_trait(cx: &LateContext<'_>, trait_def_id: DefId) -> bool { + let crate_name = cx.tcx.crate_name(trait_def_id.krate); + if crate_name.as_str() != "gpui" { + return false; + } + let name = cx.tcx.item_name(trait_def_id); + name.as_str() == "Render" || name.as_str() == "RenderOnce" +} + +/// Returns `true` when `ty` is `gpui::Entity` or `gpui::WeakEntity`. +pub(crate) fn is_gpui_entity_or_weak(cx: &LateContext<'_>, ty: Ty<'_>) -> bool { + let peeled = ty.peel_refs(); + let Some(adt) = peeled.ty_adt_def() else { + return false; + }; + let did = adt.did(); + let crate_name = cx.tcx.crate_name(did.krate); + if crate_name.as_str() != "gpui" { + return false; + } + let name = cx.tcx.item_name(did); + name.as_str() == "Entity" || name.as_str() == "WeakEntity" +} + +/// Returns `true` when `ty` is `gpui::Context`. +pub(crate) fn is_gpui_context(cx: &LateContext<'_>, ty: Ty<'_>) -> bool { + let peeled = ty.peel_refs(); + let Some(adt) = peeled.ty_adt_def() else { + return false; + }; + let did = adt.did(); + let crate_name = cx.tcx.crate_name(did.krate); + if crate_name.as_str() != "gpui" { + return false; + } + cx.tcx.item_name(did).as_str() == "Context" +} + +/// Returns `true` when the expression type indicates a unit-returning update +/// call — either `()` (from `Entity::update`) or `Result<(), _>` (from +/// `WeakEntity::update`). +pub(crate) fn is_unit_or_result_unit(cx: &LateContext<'_>, ty: Ty<'_>) -> bool { + if ty.is_unit() { + return true; + } + if let Some(adt) = ty.ty_adt_def() { + let path = cx.tcx.def_path_str(adt.did()); + if path == "core::result::Result" || path == "std::result::Result" { + if let Some(substs) = ty.walk().nth(1) { + if let Some(inner_ty) = substs.as_type() { + return inner_ty.is_unit(); + } + } + } + } + false +} diff --git a/tooling/lints/test_fixture/Cargo.toml b/tooling/lints/test_fixture/Cargo.toml new file mode 100644 index 00000000000..a07c992855c --- /dev/null +++ b/tooling/lints/test_fixture/Cargo.toml @@ -0,0 +1,6 @@ +[workspace] +resolver = "2" +members = ["gpui", "gpui_shared_string", "consumer", "render_consumer"] + +[workspace.metadata.dylint] +libraries = [{ path = ".." }] diff --git a/tooling/lints/test_fixture/consumer/Cargo.toml b/tooling/lints/test_fixture/consumer/Cargo.toml new file mode 100644 index 00000000000..dd822953c2c --- /dev/null +++ b/tooling/lints/test_fixture/consumer/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "consumer" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +path = "src/lib.rs" + +[dependencies] +gpui_shared_string = { path = "../gpui_shared_string" } diff --git a/tooling/lints/test_fixture/consumer/src/lib.rs b/tooling/lints/test_fixture/consumer/src/lib.rs new file mode 100644 index 00000000000..6b5d905664c --- /dev/null +++ b/tooling/lints/test_fixture/consumer/src/lib.rs @@ -0,0 +1,63 @@ +use gpui_shared_string::SharedString; + +// Should fire: `from` with a short literal (≤ 23 bytes). +pub fn from_short() -> SharedString { + SharedString::from("Favorites") +} + +// Should fire at elevated severity: `from` with a long literal (> 23 bytes). +pub fn from_long() -> SharedString { + SharedString::from("Right-click for more options") +} + +// Should fire: explicit `.into()` from a string literal. +pub fn into_short() -> SharedString { + "hello".into() +} + +// Should fire: `SharedString::new("...")`. +pub fn new_short() -> SharedString { + SharedString::new("hi") +} + +// Should NOT fire: the zero-cost constructor. +pub fn new_static_short() -> SharedString { + SharedString::new_static("Favorites") +} + +// Should NOT fire: non-literal input. +pub fn from_variable(s: &str) -> SharedString { + SharedString::from(s) +} + +// Should NOT fire: `.into()` on a non-literal. +pub fn into_variable(s: &str) -> SharedString { + s.into() +} + +// Should fire: `.into()` on a string literal that exceeds the 23-byte cap. +pub fn into_long() -> SharedString { + "this literal is definitely longer than twenty three bytes".into() +} + +// ---- owned_string_into_shared cases targeting `SharedString` ---- + +// Should fire (owned_string_into_shared): `String::from().into()`. +pub fn shared_string_from_string_from() -> SharedString { + String::from("label").into() +} + +// Should fire (owned_string_into_shared): `.to_string().into()`. +pub fn shared_string_from_to_string() -> SharedString { + "label".to_string().into() +} + +// Should fire (owned_string_into_shared): `.to_owned().into()`. +pub fn shared_string_from_to_owned() -> SharedString { + "label".to_owned().into() +} + +// Should NOT fire owned_string_into_shared: the source is a non-literal `String`. +pub fn shared_string_from_dynamic_string(s: String) -> SharedString { + s.into() +} diff --git a/tooling/lints/test_fixture/gpui/Cargo.toml b/tooling/lints/test_fixture/gpui/Cargo.toml new file mode 100644 index 00000000000..5d01d426808 --- /dev/null +++ b/tooling/lints/test_fixture/gpui/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "gpui" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +path = "src/lib.rs" diff --git a/tooling/lints/test_fixture/gpui/src/lib.rs b/tooling/lints/test_fixture/gpui/src/lib.rs new file mode 100644 index 00000000000..7d863fbe836 --- /dev/null +++ b/tooling/lints/test_fixture/gpui/src/lib.rs @@ -0,0 +1,100 @@ +//! Minimal stand-in for the real `gpui` crate. The lints key off the crate +//! name and the type names, so we only need to reproduce the relevant API +//! surface. + +use std::marker::PhantomData; + +// --- AppContext --- + +pub trait AppContext { + fn as_app_mut(&mut self) -> &mut App; +} + +// --- App --- + +pub struct App; + +impl AppContext for App { + fn as_app_mut(&mut self) -> &mut App { + self + } +} + +// --- Context --- + +pub struct Context<'a, T> { + _marker: PhantomData<&'a mut T>, +} + +impl AppContext for Context<'_, T> { + fn as_app_mut(&mut self) -> &mut App { + unimplemented!() + } +} + +impl Context<'_, T> { + pub fn notify(&mut self) {} +} + +// --- Window --- + +pub struct Window; + +// --- Entity --- + +pub struct Entity { + _marker: PhantomData, +} + +impl Entity { + pub fn update( + &self, + _cx: &mut C, + _f: impl FnOnce(&mut T, &mut Context) -> R, + ) -> R { + unimplemented!() + } + + pub fn read<'a>(&self, _cx: &'a App) -> &'a T { + unimplemented!() + } + + pub fn read_with(&self, _cx: &App, _f: impl FnOnce(&T, &App) -> R) -> R { + unimplemented!() + } + + pub fn downgrade(&self) -> WeakEntity { + unimplemented!() + } +} + +// --- WeakEntity --- + +pub struct WeakEntity { + _marker: PhantomData, +} + +impl WeakEntity { + pub fn update( + &self, + _cx: &mut C, + _f: impl FnOnce(&mut T, &mut Context) -> R, + ) -> Result { + unimplemented!() + } +} + +// --- Render traits --- + +pub trait IntoElement {} + +pub trait Render: 'static + Sized { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement; +} + +pub trait RenderOnce: 'static { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement; +} + +impl IntoElement for () {} +impl IntoElement for &str {} diff --git a/tooling/lints/test_fixture/gpui_shared_string/Cargo.toml b/tooling/lints/test_fixture/gpui_shared_string/Cargo.toml new file mode 100644 index 00000000000..4bb3411b825 --- /dev/null +++ b/tooling/lints/test_fixture/gpui_shared_string/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "gpui_shared_string" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +path = "src/lib.rs" diff --git a/tooling/lints/test_fixture/gpui_shared_string/src/lib.rs b/tooling/lints/test_fixture/gpui_shared_string/src/lib.rs new file mode 100644 index 00000000000..bce372c06ee --- /dev/null +++ b/tooling/lints/test_fixture/gpui_shared_string/src/lib.rs @@ -0,0 +1,31 @@ +//! Minimal stand-in for the real `gpui_shared_string` crate. The lint keys +//! off the crate name and the type name `SharedString`, so we only need to +//! reproduce those. + +#[derive(Clone)] +pub struct SharedString(String); + +impl SharedString { + pub const fn new_static(s: &'static str) -> Self { + // The real implementation stores the `'static` pointer; we just wrap + // an empty String at compile time to keep this `const`. + let _ = s; + SharedString(String::new()) + } + + pub fn new(s: impl AsRef) -> Self { + SharedString(s.as_ref().to_owned()) + } +} + +impl From<&str> for SharedString { + fn from(s: &str) -> Self { + SharedString(s.to_owned()) + } +} + +impl From for SharedString { + fn from(s: String) -> Self { + SharedString(s) + } +} diff --git a/tooling/lints/test_fixture/render_consumer/Cargo.toml b/tooling/lints/test_fixture/render_consumer/Cargo.toml new file mode 100644 index 00000000000..25a93028de2 --- /dev/null +++ b/tooling/lints/test_fixture/render_consumer/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "render_consumer" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +path = "src/lib.rs" + +[dependencies] +gpui = { path = "../gpui" } diff --git a/tooling/lints/test_fixture/render_consumer/src/lib.rs b/tooling/lints/test_fixture/render_consumer/src/lib.rs new file mode 100644 index 00000000000..e578844dcdb --- /dev/null +++ b/tooling/lints/test_fixture/render_consumer/src/lib.rs @@ -0,0 +1,154 @@ +#![allow(unused, dead_code)] + +use gpui::*; + +// ============================================================ +// Helper types for tests +// ============================================================ + +struct Editor; + +impl Editor { + fn set_text(&mut self, _text: &str) {} + fn text(&self) -> String { + String::new() + } +} + +struct Counter { + count: u32, + editor: Entity, +} + +struct CounterOnce { + editor: Entity, + weak_editor: WeakEntity, +} + +// ============================================================ +// entity_update_in_render — SHOULD WARN +// ============================================================ + +// Entity::update with unit-returning closure in Render::render +impl Render for Counter { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.editor.update(cx, |editor, _cx| { + editor.set_text("hello"); + }); + () + } +} + +// Entity::update with unit-returning closure in RenderOnce::render +impl RenderOnce for CounterOnce { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + self.editor.update(cx, |editor, _cx| { + editor.set_text("hello"); + }); + () + } +} + +// WeakEntity::update with unit-returning closure in RenderOnce::render +struct WeakUpdater { + weak_editor: WeakEntity, +} + +impl RenderOnce for WeakUpdater { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let _ = self.weak_editor.update(cx, |editor, _cx| { + editor.set_text("world"); + }); + () + } +} + +// ============================================================ +// entity_update_in_render — SHOULD NOT WARN +// ============================================================ + +// Entity::update with value-returning closure (reading, not mutating) +struct ReaderView { + editor: Entity, +} + +impl RenderOnce for ReaderView { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let _text = self.editor.update(cx, |editor, _cx| editor.text()); + () + } +} + +// Entity::update outside render entirely +fn update_outside_render(editor: &Entity, cx: &mut App) { + editor.update(cx, |editor, _cx| { + editor.set_text("fine here"); + }); +} + +// Entity::read in render (not update) +struct ReadOnlyView { + editor: Entity, +} + +impl RenderOnce for ReadOnlyView { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let _editor = self.editor.read(cx); + () + } +} + +// Entity::update inside a closure in render (simulating an event handler +// that executes later, not during render itself) +struct ClosureView { + editor: Entity, +} + +impl RenderOnce for ClosureView { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let editor = self.editor; + let _handler = move |cx: &mut App| { + editor.update(cx, |editor, _cx| { + editor.set_text("inside closure"); + }); + }; + () + } +} + +// ============================================================ +// notify_in_render — SHOULD WARN +// ============================================================ + +struct NotifyView { + count: u32, +} + +impl Render for NotifyView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.count += 1; + cx.notify(); + () + } +} + +// ============================================================ +// notify_in_render — SHOULD NOT WARN +// ============================================================ + +// notify outside render +fn notify_outside_render(cx: &mut Context<'_, T>) { + cx.notify(); +} + +// notify inside a closure in render (event handler — runs later) +struct NotifyClosureView; + +impl Render for NotifyClosureView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let _handler = |cx: &mut Context<'_, Self>| { + cx.notify(); + }; + () + } +} diff --git a/tooling/lints/ui/async_block_without_await.rs b/tooling/lints/ui/async_block_without_await.rs new file mode 100644 index 00000000000..f95de0aa1f9 --- /dev/null +++ b/tooling/lints/ui/async_block_without_await.rs @@ -0,0 +1,79 @@ +// Tests for the `async_block_without_await` lint. + +#![allow(unused)] + +async fn returns_42() -> i32 { + 42 +} + +fn sync_fn() -> i32 { + 42 +} + +fn main() { + // --- Should warn --- + + // Empty async block. + let _f = async {}; + + // Async block with a pure expression. + let _f = async { 42 }; + + // Async move block without await. + let _f = async move { + let x = 1; + x + 2 + }; + + // Async block calling a sync function. + let _f = async { sync_fn() }; + + // Nested: the *inner* async block has no await (should warn on it). + // The outer block awaits the inner future, so the outer is fine. + let _f = async { + let inner = async { 42 }; + inner.await + }; + + // --- Should NOT warn --- + + // Async block with an await. + let _f = async { returns_42().await }; + + // Async block with await in a let binding. + let _f = async { + let x = returns_42().await; + x + 1 + }; + + // Async move block with await. + let _f = async move { returns_42().await }; + + // Regular closure (not async at all). + let _f = || 42; +} + +// --- Trait impl cases --- + +use std::future::Future; + +trait AsyncWork { + fn do_work(&self) -> impl Future; +} + +struct Worker; + +// Should NOT warn: the trait requires returning a future, so the +// implementor has no choice but to use `async { ... }`. +impl AsyncWork for Worker { + fn do_work(&self) -> impl Future { + async { 42 } + } +} + +// Should warn: inherent impl — the author chose `async` freely. +impl Worker { + fn compute(&self) -> impl Future { + async { 42 } + } +} diff --git a/tooling/lints/ui/async_block_without_await.stderr b/tooling/lints/ui/async_block_without_await.stderr new file mode 100644 index 00000000000..e5f5197af5e --- /dev/null +++ b/tooling/lints/ui/async_block_without_await.stderr @@ -0,0 +1,56 @@ +error: this `async` block contains no `.await` + --> $DIR/async_block_without_await.rs:17:14 + | +LL | let _f = async {}; + | ^^^^^^^^ + | + = help: consider removing the `async` block or adding the missing `.await` + = note: `-D async-block-without-await` implied by `-D warnings` + = help: to override `-D warnings` add `#[allow(async_block_without_await)]` + +error: this `async` block contains no `.await` + --> $DIR/async_block_without_await.rs:20:14 + | +LL | let _f = async { 42 }; + | ^^^^^^^^^^^^ + | + = help: consider removing the `async` block or adding the missing `.await` + +error: this `async` block contains no `.await` + --> $DIR/async_block_without_await.rs:23:14 + | +LL | let _f = async move { + | ______________^ +LL | | let x = 1; +LL | | x + 2 +LL | | }; + | |_____^ + | + = help: consider removing the `async` block or adding the missing `.await` + +error: this `async` block contains no `.await` + --> $DIR/async_block_without_await.rs:29:14 + | +LL | let _f = async { sync_fn() }; + | ^^^^^^^^^^^^^^^^^^^ + | + = help: consider removing the `async` block or adding the missing `.await` + +error: this `async` block contains no `.await` + --> $DIR/async_block_without_await.rs:34:21 + | +LL | let inner = async { 42 }; + | ^^^^^^^^^^^^ + | + = help: consider removing the `async` block or adding the missing `.await` + +error: this `async` block contains no `.await` + --> $DIR/async_block_without_await.rs:77:9 + | +LL | async { 42 } + | ^^^^^^^^^^^^ + | + = help: consider removing the `async` block or adding the missing `.await` + +error: aborting due to 6 previous errors + diff --git a/tooling/lints/ui/blocking_io_on_foreground.rs b/tooling/lints/ui/blocking_io_on_foreground.rs new file mode 100644 index 00000000000..f645bebbadc --- /dev/null +++ b/tooling/lints/ui/blocking_io_on_foreground.rs @@ -0,0 +1,324 @@ +// Tests for the `blocking_io_on_foreground` lint. + +#![allow(unused, let_underscore_lock)] + +extern crate gpui; + +use gpui::*; + +struct Editor; + +// ============================================================ +// SHOULD WARN — blocking IO in functions with GPUI context params +// ============================================================ + +// --- std::fs free functions --- + +fn read_config_with_app(cx: &mut App) { + let _ = std::fs::read_to_string("config.toml"); +} + +fn write_file_with_app(cx: &App) { + let _ = std::fs::write("out.txt", b"data"); +} + +fn read_with_window(window: &mut Window, cx: &mut App) { + let _ = std::fs::read("data.bin"); +} + +fn metadata_with_app(cx: &mut App) { + let _ = std::fs::metadata("file.txt"); +} + +fn create_dir_with_app(cx: &mut App) { + let _ = std::fs::create_dir_all("some/path"); +} + +fn remove_file_with_app(cx: &mut App) { + let _ = std::fs::remove_file("old.txt"); +} + +fn canonicalize_with_app(cx: &mut App) { + let _ = std::fs::canonicalize("./relative"); +} + +fn read_dir_with_app(cx: &mut App) { + let _ = std::fs::read_dir("some/dir"); +} + +fn read_link_with_app(cx: &mut App) { + let _ = std::fs::read_link("some/link"); +} + +fn symlink_metadata_with_app(cx: &mut App) { + let _ = std::fs::symlink_metadata("file.txt"); +} + +fn set_permissions_with_app(cx: &mut App) { + if let Ok(meta) = std::fs::metadata("file.txt") { + let _ = std::fs::set_permissions("file.txt", meta.permissions()); + } +} + +fn copy_with_app(cx: &mut App) { + let _ = std::fs::copy("a.txt", "b.txt"); +} + +fn rename_with_app(cx: &mut App) { + let _ = std::fs::rename("old.txt", "new.txt"); +} + +fn hard_link_with_app(cx: &mut App) { + let _ = std::fs::hard_link("original", "link"); +} + +fn create_dir_with_app_single(cx: &mut App) { + let _ = std::fs::create_dir("one_dir"); +} + +fn remove_dir_with_app(cx: &mut App) { + let _ = std::fs::remove_dir("empty_dir"); +} + +fn remove_dir_all_with_app(cx: &mut App) { + let _ = std::fs::remove_dir_all("dir_tree"); +} + +// --- std::fs::File associated functions --- + +fn file_open_with_app(cx: &mut App) { + let _ = std::fs::File::open("data.bin"); +} + +fn file_create_with_app(cx: &mut App) { + let _ = std::fs::File::create("out.bin"); +} + +fn file_create_new_with_app(cx: &mut App) { + let _ = std::fs::File::create_new("new.bin"); +} + +// --- std::fs::File instance methods --- + +fn file_sync_all_with_app(cx: &mut App) { + if let Ok(f) = std::fs::File::open("x") { + let _ = f.sync_all(); + } +} + +fn file_sync_data_with_app(cx: &mut App) { + if let Ok(f) = std::fs::File::open("x") { + let _ = f.sync_data(); + } +} + +fn file_set_len_with_app(cx: &mut App) { + if let Ok(f) = std::fs::File::open("x") { + let _ = f.set_len(0); + } +} + +fn file_metadata_with_app(cx: &mut App) { + if let Ok(f) = std::fs::File::open("x") { + let _ = f.metadata(); + } +} + +fn file_try_clone_with_app(cx: &mut App) { + if let Ok(f) = std::fs::File::open("x") { + let _ = f.try_clone(); + } +} + +// --- std::thread --- + +fn sleep_with_context(cx: &mut Context<'_, Editor>) { + std::thread::sleep(std::time::Duration::from_millis(100)); +} + +// --- std::path::Path methods --- + +fn path_metadata_with_app(cx: &mut App) { + let _ = std::path::Path::new("file.txt").metadata(); +} + +fn path_symlink_metadata_with_app(cx: &mut App) { + let _ = std::path::Path::new("link").symlink_metadata(); +} + +fn path_read_link_with_app(cx: &mut App) { + let _ = std::path::Path::new("link").read_link(); +} + +fn path_read_dir_with_app(cx: &mut App) { + let _ = std::path::Path::new("dir").read_dir(); +} + +fn path_exists_with_app(cx: &mut App) { + let _ = std::path::Path::new("file.txt").exists(); +} + +fn path_try_exists_with_app(cx: &mut App) { + let _ = std::path::Path::new("file.txt").try_exists(); +} + +fn path_is_file_with_app(cx: &mut App) { + let _ = std::path::Path::new("file.txt").is_file(); +} + +fn path_is_dir_with_app(cx: &mut App) { + let _ = std::path::Path::new("dir").is_dir(); +} + +fn path_is_symlink_with_app(cx: &mut App) { + let _ = std::path::Path::new("link").is_symlink(); +} + +fn path_canonicalize_with_app(cx: &mut App) { + let _ = std::path::Path::new("./relative").canonicalize(); +} + +// PathBuf derefs to Path, so the same methods fire. +fn pathbuf_exists_with_app(cx: &mut App) { + let _ = std::path::PathBuf::from("file.txt").exists(); +} + +// --- std::net --- + +fn tcp_listener_bind_with_app(cx: &mut App) { + let _ = std::net::TcpListener::bind("127.0.0.1:0"); +} + +fn tcp_stream_connect_with_app(cx: &mut App) { + let _ = std::net::TcpStream::connect("127.0.0.1:80"); +} + +fn tcp_stream_connect_timeout_with_app(cx: &mut App) { + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 80)); + let _ = std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_secs(1)); +} + +fn tcp_listener_accept_with_app(cx: &mut App) { + if let Ok(listener) = std::net::TcpListener::bind("127.0.0.1:0") { + let _ = listener.accept(); + } +} + +fn udp_socket_bind_with_app(cx: &mut App) { + let _ = std::net::UdpSocket::bind("127.0.0.1:0"); +} + +fn udp_socket_send_recv_with_app(cx: &mut App) { + if let Ok(socket) = std::net::UdpSocket::bind("127.0.0.1:0") { + let mut buf = [0u8; 64]; + let _ = socket.recv_from(&mut buf); + } +} + +// --- std::process --- + +fn command_output_with_app(cx: &mut App) { + let _ = std::process::Command::new("echo").output(); +} + +fn command_status_with_app(cx: &mut App) { + let _ = std::process::Command::new("echo").status(); +} + +fn command_spawn_with_app(cx: &mut App) { + let _ = std::process::Command::new("echo").spawn(); +} + +fn child_wait_with_app(cx: &mut App) { + if let Ok(mut child) = std::process::Command::new("echo").spawn() { + let _ = child.wait(); + } +} + +fn child_wait_with_output_with_app(cx: &mut App) { + if let Ok(child) = std::process::Command::new("echo").spawn() { + let _ = child.wait_with_output(); + } +} + +// --- std::sync --- + +fn mutex_lock_with_app(cx: &mut App) { + let m = std::sync::Mutex::new(42); + let _ = m.lock(); +} + +fn rwlock_read_with_app(cx: &mut App) { + let rw = std::sync::RwLock::new(42); + let _ = rw.read(); +} + +fn rwlock_write_with_app(cx: &mut App) { + let rw = std::sync::RwLock::new(42); + let _ = rw.write(); +} + +fn barrier_wait_with_app(cx: &mut App) { + let b = std::sync::Barrier::new(1); + b.wait(); +} + +fn receiver_recv_with_app(cx: &mut App) { + let (_tx, rx) = std::sync::mpsc::channel::(); + let _ = rx.recv(); +} + +fn sync_sender_send_with_app(cx: &mut App) { + let (tx, _rx) = std::sync::mpsc::sync_channel::(1); + let _ = tx.send(42); +} + +// --- Render impl --- + +struct BlockingRenderView; + +impl Render for BlockingRenderView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let _ = std::fs::read_to_string("layout.toml"); + () + } +} + +// ============================================================ +// SHOULD NOT WARN — no GPUI context, or inside closure +// ============================================================ + +// Plain function with no GPUI parameter. +fn load_config_plain() -> String { + std::fs::read_to_string("config.toml").unwrap_or_default() +} + +// Blocking IO inside a closure (could be passed to background_spawn). +fn setup_with_closure(cx: &mut App) { + let _handler = || { + let _ = std::fs::read_to_string("config.toml"); + }; +} + +// Blocking IO in a function with no GPUI types at all. +fn standalone_sleep() { + std::thread::sleep(std::time::Duration::from_millis(10)); +} + +// Path methods with no GPUI parameter. +fn path_exists_plain() -> bool { + std::path::Path::new("file.txt").exists() +} + +// Net calls with no GPUI parameter. +fn tcp_bind_plain() { + let _ = std::net::TcpListener::bind("127.0.0.1:0"); +} + +// Mutex lock with no GPUI parameter. +fn mutex_lock_plain() { + let m = std::sync::Mutex::new(0); + let _ = m.lock(); +} + +fn main() {} diff --git a/tooling/lints/ui/blocking_io_on_foreground.stderr b/tooling/lints/ui/blocking_io_on_foreground.stderr new file mode 100644 index 00000000000..fb502d22f02 --- /dev/null +++ b/tooling/lints/ui/blocking_io_on_foreground.stderr @@ -0,0 +1,395 @@ +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:18:13 + | +LL | let _ = std::fs::read_to_string("config.toml"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: `-D blocking-io-on-foreground` implied by `-D warnings` + = help: to override `-D warnings` add `#[allow(blocking_io_on_foreground)]` + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:22:13 + | +LL | let _ = std::fs::write("out.txt", b"data"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:26:13 + | +LL | let _ = std::fs::read("data.bin"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:30:13 + | +LL | let _ = std::fs::metadata("file.txt"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:34:13 + | +LL | let _ = std::fs::create_dir_all("some/path"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:38:13 + | +LL | let _ = std::fs::remove_file("old.txt"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:42:13 + | +LL | let _ = std::fs::canonicalize("./relative"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:46:13 + | +LL | let _ = std::fs::read_dir("some/dir"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:50:13 + | +LL | let _ = std::fs::read_link("some/link"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:54:13 + | +LL | let _ = std::fs::symlink_metadata("file.txt"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:58:23 + | +LL | if let Ok(meta) = std::fs::metadata("file.txt") { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:59:17 + | +LL | let _ = std::fs::set_permissions("file.txt", meta.permissions()); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:64:13 + | +LL | let _ = std::fs::copy("a.txt", "b.txt"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:68:13 + | +LL | let _ = std::fs::rename("old.txt", "new.txt"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:72:13 + | +LL | let _ = std::fs::hard_link("original", "link"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:76:13 + | +LL | let _ = std::fs::create_dir("one_dir"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:80:13 + | +LL | let _ = std::fs::remove_dir("empty_dir"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:84:13 + | +LL | let _ = std::fs::remove_dir_all("dir_tree"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:90:13 + | +LL | let _ = std::fs::File::open("data.bin"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:94:13 + | +LL | let _ = std::fs::File::create("out.bin"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:98:13 + | +LL | let _ = std::fs::File::create_new("new.bin"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:104:20 + | +LL | if let Ok(f) = std::fs::File::open("x") { + | ^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:105:17 + | +LL | let _ = f.sync_all(); + | ^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:110:20 + | +LL | if let Ok(f) = std::fs::File::open("x") { + | ^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:111:17 + | +LL | let _ = f.sync_data(); + | ^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:116:20 + | +LL | if let Ok(f) = std::fs::File::open("x") { + | ^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:117:17 + | +LL | let _ = f.set_len(0); + | ^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:122:20 + | +LL | if let Ok(f) = std::fs::File::open("x") { + | ^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:123:17 + | +LL | let _ = f.metadata(); + | ^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:128:20 + | +LL | if let Ok(f) = std::fs::File::open("x") { + | ^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:129:17 + | +LL | let _ = f.try_clone(); + | ^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:136:5 + | +LL | std::thread::sleep(std::time::Duration::from_millis(100)); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:142:13 + | +LL | let _ = std::path::Path::new("file.txt").metadata(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:146:13 + | +LL | let _ = std::path::Path::new("link").symlink_metadata(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:150:13 + | +LL | let _ = std::path::Path::new("link").read_link(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:154:13 + | +LL | let _ = std::path::Path::new("dir").read_dir(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:158:13 + | +LL | let _ = std::path::Path::new("file.txt").exists(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:162:13 + | +LL | let _ = std::path::Path::new("file.txt").try_exists(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:166:13 + | +LL | let _ = std::path::Path::new("file.txt").is_file(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:170:13 + | +LL | let _ = std::path::Path::new("dir").is_dir(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:174:13 + | +LL | let _ = std::path::Path::new("link").is_symlink(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:178:13 + | +LL | let _ = std::path::Path::new("./relative").canonicalize(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:183:13 + | +LL | let _ = std::path::PathBuf::from("file.txt").exists(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:189:13 + | +LL | let _ = std::net::TcpListener::bind("127.0.0.1:0"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:193:13 + | +LL | let _ = std::net::TcpStream::connect("127.0.0.1:80"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:198:13 + | +LL | let _ = std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_secs(1)); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:202:27 + | +LL | if let Ok(listener) = std::net::TcpListener::bind("127.0.0.1:0") { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:203:17 + | +LL | let _ = listener.accept(); + | ^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:208:13 + | +LL | let _ = std::net::UdpSocket::bind("127.0.0.1:0"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:212:25 + | +LL | if let Ok(socket) = std::net::UdpSocket::bind("127.0.0.1:0") { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:214:17 + | +LL | let _ = socket.recv_from(&mut buf); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:221:13 + | +LL | let _ = std::process::Command::new("echo").output(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:225:13 + | +LL | let _ = std::process::Command::new("echo").status(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:229:13 + | +LL | let _ = std::process::Command::new("echo").spawn(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:233:28 + | +LL | if let Ok(mut child) = std::process::Command::new("echo").spawn() { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:234:17 + | +LL | let _ = child.wait(); + | ^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:239:24 + | +LL | if let Ok(child) = std::process::Command::new("echo").spawn() { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:240:17 + | +LL | let _ = child.wait_with_output(); + | ^^^^^^^^^^^^^^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:248:13 + | +LL | let _ = m.lock(); + | ^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:253:13 + | +LL | let _ = rw.read(); + | ^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:258:13 + | +LL | let _ = rw.write(); + | ^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:263:5 + | +LL | b.wait(); + | ^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:268:13 + | +LL | let _ = rx.recv(); + | ^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:273:13 + | +LL | let _ = tx.send(42); + | ^^^^^^^^^^^ + +error: blocking IO call on the GPUI foreground thread + --> $DIR/blocking_io_on_foreground.rs:282:17 + | +LL | let _ = std::fs::read_to_string("layout.toml"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: aborting due to 65 previous errors + diff --git a/tooling/lints/ui/entity_update_in_render.rs b/tooling/lints/ui/entity_update_in_render.rs new file mode 100644 index 00000000000..d9e733902a1 --- /dev/null +++ b/tooling/lints/ui/entity_update_in_render.rs @@ -0,0 +1,116 @@ +// Tests for the `entity_update_in_render` lint. + +#![allow(unused)] + +extern crate gpui; + +use gpui::*; + +struct Counter { + value: i32, +} + +// ============================================================ +// SHOULD WARN — .update() returning () inside Render::render +// ============================================================ + +struct MutatingView { + counter: Entity, +} + +impl Render for MutatingView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.counter.update(cx, |counter, _cx| { + counter.value += 1; + }); + () + } +} + +// WeakEntity::update returning Result<(), _> inside Render::render +struct WeakMutatingView { + counter: WeakEntity, +} + +impl Render for WeakMutatingView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let _ = self.counter.update(cx, |counter, _cx| { + counter.value += 1; + }); + () + } +} + +// Entity::update returning () inside RenderOnce::render +struct OnceMutatingView { + counter: Entity, +} + +impl RenderOnce for OnceMutatingView { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + self.counter.update(cx, |counter, _cx| { + counter.value += 1; + }); + () + } +} + +// ============================================================ +// SHOULD NOT WARN +// ============================================================ + +// .update() returning a value (reading, not mutating) +struct ReadingView { + counter: Entity, +} + +impl Render for ReadingView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let _val: i32 = self.counter.update(cx, |counter, _cx| counter.value); + () + } +} + +// .update() inside a closure (e.g. event handler), not directly in render +struct ClosureView { + counter: Entity, +} + +impl Render for ClosureView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let counter = &self.counter; + let _handler = |cx: &mut App| { + counter.update(cx, |counter, _cx| { + counter.value += 1; + }); + }; + () + } +} + +// .update() outside of render entirely +fn update_outside_render(entity: &Entity, cx: &mut App) { + entity.update(cx, |counter, _cx| { + counter.value += 1; + }); +} + +// .update() on a non-gpui type (unrelated method named "update") +struct FakeEntity; + +impl FakeEntity { + fn update(&self) {} +} + +struct FakeView { + thing: FakeEntity, +} + +impl Render for FakeView { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + self.thing.update(); + () + } +} + +fn main() {} diff --git a/tooling/lints/ui/entity_update_in_render.stderr b/tooling/lints/ui/entity_update_in_render.stderr new file mode 100644 index 00000000000..53d9b7d9927 --- /dev/null +++ b/tooling/lints/ui/entity_update_in_render.stderr @@ -0,0 +1,30 @@ +error: entity `.update()` called during render mutates state in the render pass + --> $DIR/entity_update_in_render.rs:23:9 + | +LL | / self.counter.update(cx, |counter, _cx| { +LL | | counter.value += 1; +LL | | }); + | |__________^ + | + = note: `-D entity-update-in-render` implied by `-D warnings` + = help: to override `-D warnings` add `#[allow(entity_update_in_render)]` + +error: entity `.update()` called during render mutates state in the render pass + --> $DIR/entity_update_in_render.rs:37:17 + | +LL | let _ = self.counter.update(cx, |counter, _cx| { + | _________________^ +LL | | counter.value += 1; +LL | | }); + | |__________^ + +error: entity `.update()` called during render mutates state in the render pass + --> $DIR/entity_update_in_render.rs:51:9 + | +LL | / self.counter.update(cx, |counter, _cx| { +LL | | counter.value += 1; +LL | | }); + | |__________^ + +error: aborting due to 3 previous errors + diff --git a/tooling/lints/ui/owned_string_into_shared.rs b/tooling/lints/ui/owned_string_into_shared.rs new file mode 100644 index 00000000000..b4dd0aa9f54 --- /dev/null +++ b/tooling/lints/ui/owned_string_into_shared.rs @@ -0,0 +1,68 @@ +// Tests for the `owned_string_into_shared` lint. + +#![allow(unused)] + +use std::borrow::Cow; +use std::rc::Rc; +use std::sync::Arc; + +fn main() { + // --- Should warn --- + + // String::from().into() into Arc. + let _a: Arc = String::from("hello").into(); + + // String::from().into() into Rc. + let _b: Rc = String::from("world").into(); + + // String::from().into() into Cow<'_, str>. + let _c: Cow<'_, str> = String::from("borrowed-or-owned").into(); + + // .to_string().into() into Arc. + let _d: Arc = "via-to-string".to_string().into(); + + // .to_owned().into() into Arc. + let _e: Arc = "via-to-owned".to_owned().into(); + + // .to_string().into() into Rc. + let _f: Rc = "rc-via-to-string".to_string().into(); + + // .to_owned().into() into Cow<'_, str>. + let _g: Cow<'_, str> = "cow-via-to-owned".to_owned().into(); + + // Long literal still flagged the same way. + let _h: Arc = + String::from("this literal is definitely longer than twenty three bytes").into(); + + // --- Should NOT warn --- + + // Direct construction from the literal — already optimal. + let _ok1: Arc = Arc::from("hello"); + let _ok2: Rc = Rc::from("world"); + let _ok3: Cow<'_, str> = Cow::Borrowed("borrowed"); + + // Producing a plain `String` (not a refcounted destination). + let _ok4: String = String::from("not refcounted"); + let _ok5: String = "x".to_string(); + let _ok6: String = "x".to_owned(); + + // `.into()` from a non-literal `String` — the allocation is unavoidable. + let dynamic: String = make_string(); + let _ok7: Arc = dynamic.into(); + + // `.into()` from a `&str` directly (no owned `String` in between). + let _ok8: Arc = "direct".into(); + + // `.into()` whose destination is not one of the targeted types. + let _ok9: Box = String::from("box-str").into(); + + // `String::new()` is not built from a literal. + let _ok10: Arc = String::new().into(); + + // Method call that is not `into`. + let _ok11: String = String::from("foo").clone(); +} + +fn make_string() -> String { + String::from("dynamic") +} diff --git a/tooling/lints/ui/owned_string_into_shared.stderr b/tooling/lints/ui/owned_string_into_shared.stderr new file mode 100644 index 00000000000..ec6e13ea508 --- /dev/null +++ b/tooling/lints/ui/owned_string_into_shared.stderr @@ -0,0 +1,53 @@ +error: this allocates an owned `String` from a string literal only to convert it into a refcounted string + --> $DIR/owned_string_into_shared.rs:13:24 + | +LL | let _a: Arc = String::from("hello").into(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: `-D owned-string-into-shared` implied by `-D warnings` + = help: to override `-D warnings` add `#[allow(owned_string_into_shared)]` + +error: this allocates an owned `String` from a string literal only to convert it into a refcounted string + --> $DIR/owned_string_into_shared.rs:16:23 + | +LL | let _b: Rc = String::from("world").into(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: this allocates an owned `String` from a string literal only to convert it into a refcounted string + --> $DIR/owned_string_into_shared.rs:19:28 + | +LL | let _c: Cow<'_, str> = String::from("borrowed-or-owned").into(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: this allocates an owned `String` from a string literal only to convert it into a refcounted string + --> $DIR/owned_string_into_shared.rs:22:24 + | +LL | let _d: Arc = "via-to-string".to_string().into(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: this allocates an owned `String` from a string literal only to convert it into a refcounted string + --> $DIR/owned_string_into_shared.rs:25:24 + | +LL | let _e: Arc = "via-to-owned".to_owned().into(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: this allocates an owned `String` from a string literal only to convert it into a refcounted string + --> $DIR/owned_string_into_shared.rs:28:23 + | +LL | let _f: Rc = "rc-via-to-string".to_string().into(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: this allocates an owned `String` from a string literal only to convert it into a refcounted string + --> $DIR/owned_string_into_shared.rs:31:28 + | +LL | let _g: Cow<'_, str> = "cow-via-to-owned".to_owned().into(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: this allocates an owned `String` from a string literal only to convert it into a refcounted string + --> $DIR/owned_string_into_shared.rs:35:9 + | +LL | String::from("this literal is definitely longer than twenty three bytes").into(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: aborting due to 8 previous errors + From 1a99eba1926a2776cfb39be3dca922cf08483af7 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Sat, 4 Jul 2026 02:29:01 +0300 Subject: [PATCH 068/197] Do not redownload same Nightly updates over and over (#59994) If one gets a Nightly update but does not restart and rather dismisses the notification, the next update check will do the update again. This is caused by the fact that the version check is done based on semver, but Nightly has the very same version that differs by the SHA only. Adjust the Nightly update check to skip re-downloads if SHA parts of the version match also. https://github.com/zed-industries/zed/pull/59852 does more changes, this PR extracts the part I am sure about. Release Notes: - N/A --- crates/auto_update/src/auto_update.rs | 171 ++++++++++++------------- crates/title_bar/src/update_version.rs | 39 +++--- 2 files changed, 101 insertions(+), 109 deletions(-) diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index 126e75ae838..e4c837618d9 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -109,12 +109,6 @@ actions!( ] ); -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum VersionCheckType { - Sha(AppCommitSha), - Semantic(Version), -} - #[derive(Serialize, Debug)] pub struct AssetQuery<'a> { asset: &'a str, @@ -130,16 +124,16 @@ pub enum AutoUpdateStatus { Idle, Checking, Downloading { - version: VersionCheckType, + version: Version, /// Download progress as a fraction in the range `0.0..=1.0`, or `None` /// when the total download size is not yet known. progress: Option, }, Installing { - version: VersionCheckType, + version: Version, }, Updated { - version: VersionCheckType, + version: Version, }, Errored { error: Arc, @@ -800,49 +794,33 @@ impl AutoUpdater { installed_version: Version, fetched_version: String, status: AutoUpdateStatus, - ) -> Result> { - let parsed_fetched_version = fetched_version.parse::(); - - if let AutoUpdateStatus::Updated { version, .. } = status { - match version { - VersionCheckType::Sha(cached_version) => { - let should_download = - parsed_fetched_version.as_ref().ok().is_none_or(|version| { - version.build.as_str().rsplit('.').next() - != Some(&cached_version.full()) - }); - let newer_version = should_download - .then(|| VersionCheckType::Sha(AppCommitSha::new(fetched_version))); - return Ok(newer_version); - } - VersionCheckType::Semantic(cached_version) => { - return Self::check_if_fetched_version_is_newer_non_nightly( - cached_version, - parsed_fetched_version?, - ); - } - } - } + ) -> Result> { + let fetched_version = fetched_version.parse::()?; match release_channel { ReleaseChannel::Nightly => { - let should_download = app_commit_sha - .ok() - .flatten() - .map(|sha| { - parsed_fetched_version.as_ref().ok().is_none_or(|version| { - version.build.as_str().rsplit('.').next() != Some(&sha) - }) - }) - .unwrap_or(true); - let newer_version = should_download - .then(|| VersionCheckType::Sha(AppCommitSha::new(fetched_version))); - Ok(newer_version) + let should_download = if let AutoUpdateStatus::Updated { version } = status { + fetched_version != version + } else { + let fetched_sha = fetched_version.build.as_str().rsplit('.').next(); + app_commit_sha + .ok() + .flatten() + .is_none_or(|sha| fetched_sha != Some(sha.as_str())) + }; + Ok(should_download.then_some(fetched_version)) + } + _ => { + let current_version = if let AutoUpdateStatus::Updated { version } = status { + version + } else { + installed_version + }; + Ok(Self::check_if_fetched_version_is_newer_non_nightly( + current_version, + fetched_version, + )) } - _ => Self::check_if_fetched_version_is_newer_non_nightly( - installed_version, - parsed_fetched_version?, - ), } } @@ -905,13 +883,11 @@ impl AutoUpdater { fn check_if_fetched_version_is_newer_non_nightly( mut installed_version: Version, fetched_version: Version, - ) -> Result> { + ) -> Option { // For non-nightly releases, ignore build and pre-release fields as they're not provided by our endpoints right now. installed_version.pre = semver::Prerelease::EMPTY; installed_version.build = semver::BuildMetadata::EMPTY; - let should_download = fetched_version > installed_version; - let newer_version = should_download.then(|| VersionCheckType::Semantic(fetched_version)); - Ok(newer_version) + (fetched_version > installed_version).then_some(fetched_version) } pub fn set_should_show_update_notification( @@ -1366,7 +1342,7 @@ mod tests { assert_eq!( status, AutoUpdateStatus::Downloading { - version: VersionCheckType::Semantic(semver::Version::new(0, 100, 1)), + version: semver::Version::new(0, 100, 1), progress: None, } ); @@ -1397,7 +1373,7 @@ mod tests { assert_eq!( status, AutoUpdateStatus::Updated { - version: VersionCheckType::Semantic(semver::Version::new(0, 100, 1)) + version: semver::Version::new(0, 100, 1) } ); let will_restart = cx.expect_restart(); @@ -1550,10 +1526,7 @@ mod tests { status, ); - assert_eq!( - newer_version.unwrap(), - Some(VersionCheckType::Semantic(fetched_version)) - ); + assert_eq!(newer_version.unwrap(), Some(fetched_version)); } #[test] @@ -1562,7 +1535,7 @@ mod tests { let app_commit_sha = Ok(Some("a".to_string())); let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Updated { - version: VersionCheckType::Semantic(semver::Version::new(1, 0, 1)), + version: semver::Version::new(1, 0, 1), }; let fetched_version = semver::Version::new(1, 0, 1); @@ -1583,7 +1556,7 @@ mod tests { let app_commit_sha = Ok(Some("a".to_string())); let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Updated { - version: VersionCheckType::Semantic(semver::Version::new(1, 0, 1)), + version: semver::Version::new(1, 0, 1), }; let fetched_version = semver::Version::new(1, 0, 2); @@ -1595,10 +1568,7 @@ mod tests { status, ); - assert_eq!( - newer_version.unwrap(), - Some(VersionCheckType::Semantic(fetched_version)) - ); + assert_eq!(newer_version.unwrap(), Some(fetched_version)); } #[test] @@ -1608,13 +1578,13 @@ mod tests { let mut installed_version = semver::Version::new(1, 0, 0); installed_version.build = semver::BuildMetadata::new("a").unwrap(); let status = AutoUpdateStatus::Idle; - let fetched_sha = "1.0.0+a".to_string(); + let fetched_version = "1.0.0+a".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, app_commit_sha, installed_version, - fetched_sha, + fetched_version, status, ); @@ -1627,19 +1597,19 @@ mod tests { let app_commit_sha = Ok(Some("a".to_string())); let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Idle; - let fetched_sha = "b".to_string(); + let fetched_version = "1.0.0+b".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, app_commit_sha, installed_version, - fetched_sha.clone(), + fetched_version.clone(), status, ); assert_eq!( newer_version.unwrap(), - Some(VersionCheckType::Sha(AppCommitSha::new(fetched_sha))) + Some(fetched_version.parse().unwrap()) ); } @@ -1650,15 +1620,15 @@ mod tests { let mut installed_version = semver::Version::new(1, 0, 0); installed_version.build = semver::BuildMetadata::new("a").unwrap(); let status = AutoUpdateStatus::Updated { - version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())), + version: "1.0.0+b".parse().unwrap(), }; - let fetched_sha = "1.0.0+b".to_string(); + let fetched_version = "1.0.0+b".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, app_commit_sha, installed_version, - fetched_sha, + fetched_version, status, ); @@ -1672,43 +1642,72 @@ mod tests { let mut installed_version = semver::Version::new(1, 0, 0); installed_version.build = semver::BuildMetadata::new("a").unwrap(); let status = AutoUpdateStatus::Updated { - version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())), + version: "1.0.0+b".parse().unwrap(), }; - let fetched_sha = "1.0.0+c".to_string(); + let fetched_version = "1.0.0+c".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, app_commit_sha, installed_version, - fetched_sha.clone(), + fetched_version.clone(), status, ); assert_eq!( newer_version.unwrap(), - Some(VersionCheckType::Sha(AppCommitSha::new(fetched_sha))) + Some(fetched_version.parse().unwrap()) ); } + #[test] + fn test_nightly_does_not_redownload_after_updating_to_fetched_version() { + let release_channel = ReleaseChannel::Nightly; + let installed_version = semver::Version::new(1, 0, 0); + let fetched_version = "1.0.0+nightly.b".to_string(); + + let newer_version = AutoUpdater::check_if_fetched_version_is_newer( + release_channel, + Ok(Some("a".to_string())), + installed_version.clone(), + fetched_version.clone(), + AutoUpdateStatus::Idle, + ) + .unwrap() + .expect("a newer nightly version should be available"); + + let next_check = AutoUpdater::check_if_fetched_version_is_newer( + release_channel, + Ok(Some("a".to_string())), + installed_version, + fetched_version, + AutoUpdateStatus::Updated { + version: newer_version, + }, + ); + + assert_eq!(next_check.unwrap(), None); + } + #[test] fn test_nightly_does_update_when_installed_versions_sha_cannot_be_retrieved() { let release_channel = ReleaseChannel::Nightly; let app_commit_sha = Ok(None); let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Idle; - let fetched_sha = "a".to_string(); + let fetched_version = "1.0.0+a".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, app_commit_sha, installed_version, - fetched_sha.clone(), + fetched_version.clone(), status, ); assert_eq!( newer_version.unwrap(), - Some(VersionCheckType::Sha(AppCommitSha::new(fetched_sha))) + Some(fetched_version.parse().unwrap()) ); } @@ -1719,15 +1718,15 @@ mod tests { let app_commit_sha = Ok(None); let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Updated { - version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())), + version: "1.0.0+b".parse().unwrap(), }; - let fetched_sha = "1.0.0+b".to_string(); + let fetched_version = "1.0.0+b".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, app_commit_sha, installed_version, - fetched_sha, + fetched_version, status, ); @@ -1741,21 +1740,21 @@ mod tests { let app_commit_sha = Ok(None); let installed_version = semver::Version::new(1, 0, 0); let status = AutoUpdateStatus::Updated { - version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())), + version: "1.0.0+b".parse().unwrap(), }; - let fetched_sha = "c".to_string(); + let fetched_version = "1.0.0+c".to_string(); let newer_version = AutoUpdater::check_if_fetched_version_is_newer( release_channel, app_commit_sha, installed_version, - fetched_sha.clone(), + fetched_version.clone(), status, ); assert_eq!( newer_version.unwrap(), - Some(VersionCheckType::Sha(AppCommitSha::new(fetched_sha))) + Some(fetched_version.parse().unwrap()) ); } } diff --git a/crates/title_bar/src/update_version.rs b/crates/title_bar/src/update_version.rs index 0c7db0d165f..622572abad3 100644 --- a/crates/title_bar/src/update_version.rs +++ b/crates/title_bar/src/update_version.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use anyhow::anyhow; -use auto_update::{AutoUpdateStatus, AutoUpdater, UpdateCheckType, VersionCheckType}; +use auto_update::{AutoUpdateStatus, AutoUpdater, UpdateCheckType}; use gpui::{Empty, Render}; use semver::Version; use ui::{UpdateButton, prelude::*}; @@ -42,14 +42,14 @@ impl UpdateVersion { let next_state = match self.status { AutoUpdateStatus::Idle => AutoUpdateStatus::Checking, AutoUpdateStatus::Checking => AutoUpdateStatus::Downloading { - version: VersionCheckType::Semantic(Version::new(1, 99, 0)), + version: Version::new(1, 99, 0), progress: Some(0.5), }, AutoUpdateStatus::Downloading { .. } => AutoUpdateStatus::Installing { - version: VersionCheckType::Semantic(Version::new(1, 99, 0)), + version: Version::new(1, 99, 0), }, AutoUpdateStatus::Installing { .. } => AutoUpdateStatus::Updated { - version: VersionCheckType::Semantic(Version::new(1, 99, 0)), + version: Version::new(1, 99, 0), }, AutoUpdateStatus::Updated { .. } => AutoUpdateStatus::Errored { error: Arc::new(anyhow!("Network timeout")), @@ -67,13 +67,8 @@ impl UpdateVersion { self.dismissed && self.status.is_updated() } - fn version_tooltip_message(version: &VersionCheckType) -> String { - format!("Update to Version: {}", { - match version { - VersionCheckType::Sha(sha) => sha.full(), - VersionCheckType::Semantic(semantic_version) => semantic_version.to_string(), - } - }) + fn version_tooltip_message(version: &Version) -> String { + format!("Update to Version: {version}") } } @@ -87,15 +82,15 @@ impl Render for UpdateVersion { UpdateButton::checking().into_any_element() } AutoUpdateStatus::Downloading { version, progress } => { - let version = Self::version_tooltip_message(&version); + let version = Self::version_tooltip_message(version); UpdateButton::downloading(version, *progress).into_any_element() } AutoUpdateStatus::Installing { version } => { - let version = Self::version_tooltip_message(&version); + let version = Self::version_tooltip_message(version); UpdateButton::installing(version).into_any_element() } AutoUpdateStatus::Updated { version } => { - let version = Self::version_tooltip_message(&version); + let version = Self::version_tooltip_message(version); UpdateButton::updated(version) .on_click(|_, _, cx| { workspace::reload(cx); @@ -124,27 +119,25 @@ impl Render for UpdateVersion { } #[cfg(test)] mod tests { - use auto_update::VersionCheckType; - use release_channel::AppCommitSha; use semver::Version; use super::*; #[test] fn test_version_tooltip_message() { - let message = UpdateVersion::version_tooltip_message(&VersionCheckType::Semantic( - Version::new(1, 0, 0), - )); + let message = UpdateVersion::version_tooltip_message(&Version::new(1, 0, 0)); assert_eq!(message, "Update to Version: 1.0.0"); - let message = UpdateVersion::version_tooltip_message(&VersionCheckType::Sha( - AppCommitSha::new("14d9a4189f058d8736339b06ff2340101eaea5af".to_string()), - )); + let message = UpdateVersion::version_tooltip_message( + &"1.0.0+nightly.14d9a4189f058d8736339b06ff2340101eaea5af" + .parse() + .unwrap(), + ); assert_eq!( message, - "Update to Version: 14d9a4189f058d8736339b06ff2340101eaea5af" + "Update to Version: 1.0.0+nightly.14d9a4189f058d8736339b06ff2340101eaea5af" ); } } From d97bbf33699a736c88d2e0ca130fbfa1ea2e9d0a Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Sat, 4 Jul 2026 06:15:51 -0300 Subject: [PATCH 069/197] agent_ui: Use a callout for the sandbox warning (#60386) Just a small tweak to use an existing component where the UI is a bit tidier. Release Notes: - N/A --- .../src/conversation_view/thread_view.rs | 44 ++++--------------- 1 file changed, 8 insertions(+), 36 deletions(-) diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 0f3c18d2245..42a22219883 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -7937,11 +7937,10 @@ impl ThreadView { reason: &SandboxNotAppliedReason, cx: &Context, ) -> AnyElement { - // (title, optional detail line) - let (title, detail): (SharedString, Option) = match reason { + let (title, detail): (SharedString, SharedString) = match reason { SandboxNotAppliedReason::ErrorLinuxWsl(error) => ( "Couldn't create a sandbox".into(), - Some(error.user_facing_message().into()), + error.user_facing_message().into(), ), SandboxNotAppliedReason::DisabledForThisThread => { // The grant only exists because an earlier command failed to @@ -7957,42 +7956,15 @@ impl ThreadView { .unwrap_or_else(|| { "Unsandboxed execution is allowed for the rest of this thread.".into() }); - ("Ran without sandbox".into(), Some(detail)) + ("Ran without sandbox".into(), detail) } }; - h_flex() - .px_2() - .py_1() - .gap_1() - .border_t_1() - .border_color(cx.theme().status().warning_border) - .bg(cx.theme().status().warning_background.opacity(0.5)) - .child( - h_flex() - .min_w_0() - .flex_1() - .gap_1p5() - .items_start() - .child( - Icon::new(IconName::Warning) - .size(IconSize::XSmall) - .color(Color::Warning), - ) - .child( - v_flex() - .min_w_0() - .gap_0p5() - .child(Label::new(title).size(LabelSize::Small).color(Color::Muted)) - .when_some(detail, |this, detail| { - this.child( - Label::new(detail) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - }), - ), - ) + Callout::new() + .severity(Severity::Warning) + .icon(IconName::Warning) + .title(title) + .description(detail) .into_any_element() } From e3b73c6b30cdc09e820823fe44542b89850d4be1 Mon Sep 17 00:00:00 2001 From: David Irvine Date: Sat, 4 Jul 2026 14:15:01 +0200 Subject: [PATCH 070/197] bedrock: Fix Claude Sonnet 5 and Fable 5 routing outside US regions (#60378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #60360 and #59016 (cc @NeelChotai @bennetbo). Claude Sonnet 5 and Claude Fable 5 cannot be invoked with on-demand throughput — AWS requires an inference profile, and only `us.*` and `global.*` profiles exist for these models ([Sonnet 5 model card](https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-sonnet-5.html), [Fable 5 model card](https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-fable-5.html)). For users in EU/APAC regions without `allow_global` enabled, Zed generated profile IDs like `eu.anthropic.claude-sonnet-5`, which fail with: ``` ResourceNotFoundException: Model not found. ``` And falling back to the bare model ID isn't an option either, since direct invocation fails with: ``` Invocation of model ID anthropic.claude-fable-5 with on-demand throughput isn't supported. Retry your request with the ID or ARN of an inference profile that contains this model. ``` Confirmed against the live AWS API — only `us.*` and `global.*` profiles exist: ``` ❯ aws bedrock list-inference-profiles --region eu-west-1 \ --query "inferenceProfileSummaries[?contains(inferenceProfileId, 'sonnet-5') || contains(inferenceProfileId, 'fable')].[inferenceProfileId,status]" \ --output table ------------------------------------------------ | ListInferenceProfiles | +------------------------------------+---------+ | global.anthropic.claude-sonnet-5 | ACTIVE | | global.anthropic.claude-fable-5 | ACTIVE | +------------------------------------+---------+ ``` This change removes both models from the EU match arm and routes them through the global inference profile when no regional profile is available. US regions continue to use the `us.*` profile. Verified working from `eu-west-1`. Note: this means requests from non-US regions route through the global profile, which may dispatch inference to any supported AWS region. That's inherent to how AWS exposes these models — there is no EU-resident option today. Release Notes: - Fixed Claude Sonnet 5 and Claude Fable 5 failing on Amazon Bedrock in non-US regions. --------- Co-authored-by: Neel --- crates/bedrock/src/models.rs | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/bedrock/src/models.rs b/crates/bedrock/src/models.rs index 57c1eea0166..ce5241a2dbb 100644 --- a/crates/bedrock/src/models.rs +++ b/crates/bedrock/src/models.rs @@ -765,11 +765,9 @@ impl Model { // EU region inference profiles ( - Self::ClaudeFable5 - | Self::ClaudeOpus4_8 + Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 - | Self::ClaudeSonnet5 | Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4 @@ -811,6 +809,8 @@ impl Model { "apac", ) => Ok(format!("{}.{}", region_group, model_id)), + (Self::ClaudeFable5 | Self::ClaudeSonnet5, _) => Ok(format!("global.{}", model_id)), + // Default: use model ID directly _ => Ok(model_id.into()), } @@ -876,13 +876,26 @@ mod tests { Model::ClaudeOpus4_8.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-opus-4-8" ); + Ok(()) + } + + #[test] + fn test_inference_profile_only_models_fall_back_to_global() -> anyhow::Result<()> { assert_eq!( Model::ClaudeFable5.cross_region_inference_id("eu-west-1", false)?, - "eu.anthropic.claude-fable-5" + "global.anthropic.claude-fable-5" ); assert_eq!( Model::ClaudeSonnet5.cross_region_inference_id("eu-west-1", false)?, - "eu.anthropic.claude-sonnet-5" + "global.anthropic.claude-sonnet-5" + ); + assert_eq!( + Model::ClaudeFable5.cross_region_inference_id("ap-southeast-2", false)?, + "global.anthropic.claude-fable-5" + ); + assert_eq!( + Model::ClaudeSonnet5.cross_region_inference_id("ap-northeast-1", false)?, + "global.anthropic.claude-sonnet-5" ); Ok(()) } From 6e9ff7a4f31ad4baa467058765f5fee00e4c2bc7 Mon Sep 17 00:00:00 2001 From: di404 Date: Sun, 5 Jul 2026 11:54:54 +0800 Subject: [PATCH 071/197] Fix agent hyperlinks do not open files (#56283) ## Summary Fix agent message hyperlinks that point to Windows file paths but are not parsed as openable project paths. This covers links like: ```md [Cargo.toml]() [filename.ext](C:\Projects\Example%20Workspace\path\to\filename.ext:42) [AGENTS.md]() ``` ## Problem Agent responses can emit Markdown hyperlinks whose targets are Windows paths rather than `file://` URLs. Some of those targets include a leading slash before the drive (`/C:/...`), Git Bash/MSYS-style drive prefixes (`/c/...`), percent-escaped spaces, or line suffixes. These were not normalized before mention parsing, so clicking the hyperlink could do nothing instead of opening the file. ## Solution - Normalize hyperlink path targets before parsing them as `MentionUri` paths. - Decode percent escapes in bare path targets so `%20` becomes a literal space before path/line parsing. - Convert Windows-compatible hyperlink paths such as `/C:/...` and `/c/...` into native Windows paths. - Generate file resource links from `find_path_tool` through `MentionUri::to_uri()` instead of hand-building `file://` strings. ## Result Before: clicking agent path hyperlinks did not open the referenced file. After: https://github.com/user-attachments/assets/6c7fad77-4a1e-4497-a4f9-4a4fdf86d527 ## Validation - `cargo test -p acp_thread test_parse_windows --features test-support` - `cargo fmt --check` ## Follow-up changes Additions on top of the original work above: - Moved the hyperlink heuristics into a dedicated `MentionUri::parse_hyperlink` entry point. `MentionUri::parse` stays strict, so canonical mention URIs round-trip verbatim and other callers (message editor, thread deserialization, resource links) are unaffected. - Percent escapes that decode to path separators (`%2F`, `%5C`) are left encoded, so decoding can never change which directories a path traverses. - Bare paths with escapes are ambiguous (a file may literally be named `a%20b.rs`): `open_link` prefers the decoded interpretation and falls back to `MentionUri::parse_hyperlink_literal` when the decoded path doesn't resolve in the project but the literal one does. - Links to files outside the project's worktrees now open, gated by an async existence check through the project `Fs` (correct for remote projects; broken links no longer create empty buffers or add worktrees). `open_link` and the mention-crease open path are unified into one `open_abs_path_at_point`, which now also places the cursor for out-of-project selection/symbol links. - `grep_tool` resource links also go through `MentionUri::to_uri()` now, fixing malformed `file://C:\...` URIs and unencoded spaces. - Added tests: percent-escape disambiguation, out-of-project link opening, drive-letter normalization, UNC paths, and literal-path parsing (`cargo test -p acp_thread mention`, `cargo test -p agent_ui open_link`, `cargo test -p agent grep_tool`); manually verified the link spellings above on Windows. Release Notes: - Fixed agent path hyperlinks on Windows when paths contain spaces or shell-style drive prefixes. --------- Co-authored-by: Martin Ye --- crates/acp_thread/src/mention.rs | 616 ++++++++++++++++-- crates/agent/src/tools/find_path_tool.rs | 6 +- crates/agent/src/tools/grep_tool.rs | 34 +- crates/agent_ui/src/agent_ui.rs | 56 +- .../src/conversation_view/thread_view.rs | 151 ++++- crates/agent_ui/src/ui/mention_crease.rs | 41 +- 6 files changed, 770 insertions(+), 134 deletions(-) diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index b5e6ab90ab9..93e7b4d846b 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -83,33 +83,6 @@ impl MentionUri { .and_then(|input| input.strip_suffix('`')) .unwrap_or(input); - fn parse_line_range(fragment: &str) -> Result> { - let range = fragment.strip_prefix("L").unwrap_or(fragment); - - let (start, end) = if let Some((start, end)) = range.split_once(":") { - (start, end) - } else if let Some((start, end)) = range.split_once("-") { - // Also handle L10-20 or L10-L20 format - (start, end.strip_prefix("L").unwrap_or(end)) - } else { - // Single line number like L1872 - treat as a range of one line - (range, range) - }; - - let start_line = start - .parse::() - .context("Parsing line range start")? - .checked_sub(1) - .context("Line numbers should be 1-based")?; - let end_line = end - .parse::() - .context("Parsing line range end")? - .checked_sub(1) - .context("Line numbers should be 1-based")?; - - Ok(start_line..=end_line) - } - let parse_column = |input: Option| -> Option { input?.parse::().ok()?.checked_sub(1) }; let validate_query_params = |url: &Url, allowed: &[&str]| -> Result<()> { @@ -121,37 +94,6 @@ impl MentionUri { Ok(()) }; - let parse_absolute_path = |input: &str| -> Result { - let (path_input, fragment) = input - .split_once('#') - .map_or((input, None), |(path, fragment)| (path, Some(fragment))); - - if let Some(fragment) = fragment.and_then(|fragment| parse_line_range(fragment).ok()) { - return Ok(MentionUri::Selection { - abs_path: Some(path_input.into()), - line_range: fragment, - column: None, - }); - } - - let path_with_position = PathWithPosition::parse_str(path_input); - let abs_path = path_with_position.path; - if let Some(row) = path_with_position.row { - let line = row - .checked_sub(1) - .context("Line numbers should be 1-based")?; - Ok(MentionUri::Selection { - abs_path: Some(abs_path), - line_range: line..=line, - column: path_with_position - .column - .map(|column| column.saturating_sub(1)), - }) - } else { - Ok(MentionUri::File { abs_path }) - } - }; - if is_absolute(input, path_style) && !input.contains("://") { return parse_absolute_path(input) .with_context(|| format!("Invalid absolute path mention URI: {input}")); @@ -168,7 +110,10 @@ impl MentionUri { }; let decoded = decode(trimmed).unwrap_or(Cow::Borrowed(trimmed)); let normalized: Cow = if path_style.is_windows() { - Cow::Owned(decoded.replace('/', "\\")) + match to_native_windows_path(&decoded) { + Some(native) => Cow::Owned(native), + None => decoded, + } } else { decoded }; @@ -337,6 +282,56 @@ impl MentionUri { } } + /// Parses a hyperlink target from agent-authored Markdown. + /// + /// Unlike [`MentionUri::parse`] — which stays strict so canonical mention + /// URIs round-trip verbatim — bare path targets are normalized first: + /// percent escapes are decoded (see [`decode_path_escapes`]) and + /// Windows-compatible spellings like `/C:/foo` or `/c/foo` become native + /// paths (see [`to_native_windows_path`]). + pub fn parse_hyperlink(input: &str, path_style: PathStyle) -> Result { + if let Some(target) = bare_path_target(input, path_style) { + return parse_hyperlink_path(target, path_style, DecodePercentEscapes::Yes) + .with_context(|| format!("Invalid hyperlink path target: {input}")); + } + Self::parse(input, path_style) + } + + /// Returns the literal (un-decoded) interpretation of a bare-path + /// hyperlink target, for files whose names literally contain an escape + /// sequence (e.g. `a%20b.rs`). Returns `None` when this wouldn't differ + /// from [`MentionUri::parse_hyperlink`], including for URLs, whose + /// escapes are unambiguous. + pub fn parse_hyperlink_literal(input: &str, path_style: PathStyle) -> Option { + let target = bare_path_target(input, path_style)?; + let (path_input, _) = split_path_fragment(target); + if !matches!(decode_path_escapes(path_input), Cow::Owned(_)) { + return None; + } + parse_hyperlink_path(target, path_style, DecodePercentEscapes::No).ok() + } + + /// The absolute path this mention refers to, if it refers to one. + pub fn abs_path(&self) -> Option<&Path> { + match self { + MentionUri::File { abs_path } + | MentionUri::Directory { abs_path } + | MentionUri::Symbol { abs_path, .. } => Some(abs_path), + MentionUri::Selection { abs_path, .. } => abs_path.as_deref(), + MentionUri::Skill { + skill_file_path, .. + } => Some(skill_file_path), + MentionUri::PastedImage { .. } + | MentionUri::Thread { .. } + | MentionUri::Rule { .. } + | MentionUri::Diagnostics { .. } + | MentionUri::Fetch { .. } + | MentionUri::TerminalSelection { .. } + | MentionUri::GitDiff { .. } + | MentionUri::MergeConflict { .. } => None, + } + } + pub fn name(&self) -> String { match self { MentionUri::File { abs_path, .. } | MentionUri::Directory { abs_path, .. } => abs_path @@ -599,6 +594,217 @@ impl fmt::Display for MentionLink<'_> { } } +#[derive(Clone, Copy, PartialEq, Eq)] +enum DecodePercentEscapes { + Yes, + No, +} + +fn parse_line_range(fragment: &str) -> Result> { + let range = fragment.strip_prefix("L").unwrap_or(fragment); + + let (start, end) = if let Some((start, end)) = range.split_once(":") { + (start, end) + } else if let Some((start, end)) = range.split_once("-") { + // Also handle L10-20 or L10-L20 format + (start, end.strip_prefix("L").unwrap_or(end)) + } else { + // Single line number like L1872 - treat as a range of one line + (range, range) + }; + + let start_line = start + .parse::() + .context("Parsing line range start")? + .checked_sub(1) + .context("Line numbers should be 1-based")?; + let end_line = end + .parse::() + .context("Parsing line range end")? + .checked_sub(1) + .context("Line numbers should be 1-based")?; + + Ok(start_line..=end_line) +} + +/// Returns the mention target as a bare absolute path (not a URL), with the +/// backticks agents sometimes add stripped. +fn bare_path_target(input: &str, path_style: PathStyle) -> Option<&str> { + let input = input + .strip_prefix('`') + .and_then(|input| input.strip_suffix('`')) + .unwrap_or(input); + (is_absolute(input, path_style) && !input.contains("://")).then_some(input) +} + +fn split_path_fragment(input: &str) -> (&str, Option<&str>) { + input + .split_once('#') + .map_or((input, None), |(path, fragment)| (path, Some(fragment))) +} + +fn parse_absolute_path(input: &str) -> Result { + let (path_input, fragment) = split_path_fragment(input); + absolute_path_mention(path_input, fragment) +} + +/// Like [`parse_absolute_path`], but normalizes hyperlink spellings first. +fn parse_hyperlink_path( + input: &str, + path_style: PathStyle, + decode_escapes: DecodePercentEscapes, +) -> Result { + let (path_input, fragment) = split_path_fragment(input); + let path_input = normalize_path_mention(path_input, path_style, decode_escapes); + absolute_path_mention(&path_input, fragment) +} + +fn absolute_path_mention(path_input: &str, fragment: Option<&str>) -> Result { + if let Some(fragment) = fragment.and_then(|fragment| parse_line_range(fragment).ok()) { + return Ok(MentionUri::Selection { + abs_path: Some(path_input.into()), + line_range: fragment, + column: None, + }); + } + + let path_with_position = PathWithPosition::parse_str(path_input); + let abs_path = path_with_position.path; + if let Some(row) = path_with_position.row { + let line = row + .checked_sub(1) + .context("Line numbers should be 1-based")?; + Ok(MentionUri::Selection { + abs_path: Some(abs_path), + line_range: line..=line, + column: path_with_position + .column + .map(|column| column.saturating_sub(1)), + }) + } else { + Ok(MentionUri::File { abs_path }) + } +} + +fn normalize_path_mention( + input: &str, + path_style: PathStyle, + decode_escapes: DecodePercentEscapes, +) -> Cow<'_, str> { + let decoded = match decode_escapes { + DecodePercentEscapes::Yes => decode_path_escapes(input), + DecodePercentEscapes::No => Cow::Borrowed(input), + }; + if !path_style.is_windows() { + return decoded; + } + match to_native_windows_path(&decoded) { + Some(native) => Cow::Owned(native), + None => decoded, + } +} + +/// Decodes percent escapes in a path, leaving separator escapes (`%2F`, +/// `%5C`) encoded so decoding can't change which directories the path +/// traverses. Invalid sequences and non-UTF-8 results leave the input +/// unchanged. Returns `Cow::Owned` iff decoding changed the input +/// (`parse_hyperlink_literal` relies on this). +fn decode_path_escapes(input: &str) -> Cow<'_, str> { + fn hex_digit(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } + } + + if !input.contains('%') { + return Cow::Borrowed(input); + } + let bytes = input.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' + && let Some(high) = bytes.get(index + 1).copied().and_then(hex_digit) + && let Some(low) = bytes.get(index + 2).copied().and_then(hex_digit) + { + let byte = (high << 4) | low; + if byte != b'/' && byte != b'\\' { + decoded.push(byte); + index += 3; + continue; + } + } + decoded.push(bytes[index]); + index += 1; + } + if decoded == bytes { + return Cow::Borrowed(input); + } + match String::from_utf8(decoded) { + Ok(decoded) => Cow::Owned(decoded), + Err(_) => Cow::Borrowed(input), + } +} + +/// Converts Windows-compatible path spellings into a native Windows path, +/// normalizing separators to backslashes and drive letters to uppercase so +/// parsed paths compare equal to worktree paths. Returns `None` when the +/// input needs no changes. +fn to_native_windows_path(path: &str) -> Option { + fn join_drive(drive: char, rest: &str) -> String { + format!( + "{}:\\{}", + drive.to_ascii_uppercase(), + rest.replace('/', "\\") + ) + } + + if let Some(rest) = path.strip_prefix('/') { + // URL-style path with a leading slash before the drive: `/C:/foo`. + let mut chars = rest.chars(); + if let (Some(drive), Some(':'), Some('/' | '\\')) = + (chars.next(), chars.next(), chars.next()) + && drive.is_ascii_alphabetic() + { + return Some(join_drive(drive, chars.as_str())); + } + + // MSYS/Git Bash style: `/c/foo`. Lowercase-only, since that's what + // those shells emit and uppercase risks misreading real directories. + let mut chars = rest.chars(); + if let (Some(drive), Some('/' | '\\')) = (chars.next(), chars.next()) + && drive.is_ascii_lowercase() + { + return Some(join_drive(drive, chars.as_str())); + } + } + + // A native path with a drive prefix: uppercase the drive and normalize + // separators, e.g. `c:/foo` or `c:\foo`. + let mut chars = path.chars(); + if let (Some(drive), Some(':')) = (chars.next(), chars.next()) + && drive.is_ascii_alphabetic() + { + if drive.is_ascii_uppercase() && !path.contains('/') { + return None; + } + return Some(format!( + "{}:{}", + drive.to_ascii_uppercase(), + chars.as_str().replace('/', "\\") + )); + } + + if path.contains('/') { + return Some(path.replace('/', "\\")); + } + + None +} + fn default_include_errors() -> bool { true } @@ -727,6 +933,298 @@ mod tests { } } + #[test] + fn test_parse_file_uri_with_spaces() { + let parsed = + MentionUri::parse("file:///C:/path%20with%20space/file.rs", PathStyle::Windows) + .unwrap(); + match parsed { + MentionUri::File { abs_path } => { + assert_eq!(abs_path, PathBuf::from("C:\\path with space\\file.rs")); + } + other => panic!("Expected File variant, got {other:?}"), + } + assert_eq!( + MentionUri::File { + abs_path: PathBuf::from("C:\\path with space\\file.rs") + } + .to_uri() + .to_string(), + "file:///C:/path%20with%20space/file.rs" + ); + } + + #[test] + fn test_parse_windows_drive_path_with_leading_slash_and_line() { + let parsed = MentionUri::parse_hyperlink( + "/C:/Projects/Example Workspace/Cargo.toml:2", + PathStyle::Windows, + ) + .unwrap(); + match parsed { + MentionUri::Selection { + abs_path: Some(abs_path), + line_range, + .. + } => { + assert_eq!( + abs_path, + PathBuf::from("C:\\Projects\\Example Workspace\\Cargo.toml") + ); + assert_eq!(line_range, 1..=1); + } + other => panic!("Expected Selection variant, got {other:?}"), + } + } + + #[test] + fn test_parse_windows_path_with_percent_escaped_spaces_and_line() { + let parsed = MentionUri::parse_hyperlink( + "C:\\Projects\\Example%20Workspace\\path\\to\\filename.ext:42", + PathStyle::Windows, + ) + .unwrap(); + match parsed { + MentionUri::Selection { + abs_path: Some(abs_path), + line_range, + .. + } => { + assert_eq!( + abs_path, + PathBuf::from("C:\\Projects\\Example Workspace\\path\\to\\filename.ext") + ); + assert_eq!(line_range, 41..=41); + } + other => panic!("Expected Selection variant, got {other:?}"), + } + } + + #[test] + fn test_parse_windows_compat_path_with_spaces() { + let parsed = MentionUri::parse_hyperlink( + "/c/Projects/Example Workspace/AGENTS.md", + PathStyle::Windows, + ) + .unwrap(); + match parsed { + MentionUri::File { abs_path } => { + assert_eq!( + abs_path, + PathBuf::from("C:\\Projects\\Example Workspace\\AGENTS.md") + ); + } + other => panic!("Expected File variant, got {other:?}"), + } + } + + #[test] + fn test_parse_windows_drive_path_with_leading_slash_and_fragment_line() { + let parsed = + MentionUri::parse_hyperlink("/C:/Projects/Cargo.toml#L4", PathStyle::Windows).unwrap(); + match parsed { + MentionUri::Selection { + abs_path: Some(abs_path), + line_range, + .. + } => { + assert_eq!(abs_path, PathBuf::from("C:\\Projects\\Cargo.toml")); + assert_eq!(line_range, 3..=3); + } + other => panic!("Expected Selection variant, got {other:?}"), + } + } + + #[test] + fn test_windows_drive_path_with_leading_slash_round_trips() { + let parsed = MentionUri::parse_hyperlink("/C:/dir/file.rs", PathStyle::Windows).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("C:\\dir\\file.rs") + } + ); + let uri = parsed.to_uri().to_string(); + assert_eq!(uri, "file:///C:/dir/file.rs"); + assert_eq!(MentionUri::parse(&uri, PathStyle::Windows).unwrap(), parsed); + } + + #[test] + fn test_parse_windows_unc_path() { + let parsed = + MentionUri::parse_hyperlink("//server/share/dir/file.rs", PathStyle::Windows).unwrap(); + match parsed { + MentionUri::File { abs_path } => { + assert_eq!(abs_path, PathBuf::from("\\\\server\\share\\dir\\file.rs")); + } + other => panic!("Expected File variant, got {other:?}"), + } + } + + #[test] + fn test_parse_windows_drive_letters_are_uppercased() { + for input in [ + "file:///c:/foo/bar.rs", + "/c:/foo/bar.rs", + "/c/foo/bar.rs", + "c:\\foo\\bar.rs", + "c:/foo/bar.rs", + ] { + let parsed = MentionUri::parse_hyperlink(input, PathStyle::Windows).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("C:\\foo\\bar.rs") + }, + "input: {input}" + ); + } + } + + #[test] + fn test_msys_style_paths_require_lowercase_drive() { + // Uppercase `/C/foo` is more likely a real directory than a drive. + let parsed = MentionUri::parse_hyperlink("/C/Users/readme.md", PathStyle::Windows).unwrap(); + match parsed { + MentionUri::File { abs_path } => { + assert_eq!(abs_path, PathBuf::from("\\C\\Users\\readme.md")); + } + other => panic!("Expected File variant, got {other:?}"), + } + } + + #[test] + fn test_posix_paths_are_not_rewritten_as_windows_drives() { + let parsed = + MentionUri::parse_hyperlink("/c/Projects/AGENTS.md", PathStyle::Posix).unwrap(); + match parsed { + MentionUri::File { abs_path } => { + assert_eq!(abs_path, PathBuf::from("/c/Projects/AGENTS.md")); + } + other => panic!("Expected File variant, got {other:?}"), + } + } + + #[test] + fn test_hyperlink_percent_escapes_are_decoded() { + let parsed = MentionUri::parse_hyperlink("/tmp/a%20b.rs", PathStyle::Posix).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("/tmp/a b.rs") + } + ); + + // Invalid escape sequences pass through unchanged. + let parsed = + MentionUri::parse_hyperlink("C:\\dir\\100%_done.txt", PathStyle::Windows).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("C:\\dir\\100%_done.txt") + } + ); + + // Separator escapes stay encoded (no introduced path traversal). + let parsed = MentionUri::parse_hyperlink("/tmp/a%2Fb.rs", PathStyle::Posix).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("/tmp/a%2Fb.rs") + } + ); + let parsed = + MentionUri::parse_hyperlink("/tmp/..%2F..%2Fsecret", PathStyle::Posix).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("/tmp/..%2F..%2Fsecret") + } + ); + } + + #[test] + fn test_parse_keeps_bare_path_targets_verbatim() { + let parsed = MentionUri::parse("/tmp/a%20b.rs", PathStyle::Posix).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("/tmp/a%20b.rs") + } + ); + + let parsed = MentionUri::parse("/c/Projects/AGENTS.md", PathStyle::Windows).unwrap(); + assert_eq!( + parsed, + MentionUri::File { + abs_path: PathBuf::from("/c/Projects/AGENTS.md") + } + ); + } + + #[test] + fn test_parse_hyperlink_literal_keeps_percent_escapes() { + let literal = + MentionUri::parse_hyperlink_literal("/tmp/a%20b.rs", PathStyle::Posix).unwrap(); + assert_eq!( + literal, + MentionUri::File { + abs_path: PathBuf::from("/tmp/a%20b.rs") + } + ); + + // Line suffixes still parse. + let literal = + MentionUri::parse_hyperlink_literal("/tmp/a%20b.rs:42", PathStyle::Posix).unwrap(); + assert_eq!( + literal, + MentionUri::Selection { + abs_path: Some(PathBuf::from("/tmp/a%20b.rs")), + line_range: 41..=41, + column: None, + } + ); + + // Windows normalization still applies. + let literal = + MentionUri::parse_hyperlink_literal("/C:/dir/a%20b.rs", PathStyle::Windows).unwrap(); + assert_eq!( + literal, + MentionUri::File { + abs_path: PathBuf::from("C:\\dir\\a%20b.rs") + } + ); + } + + #[test] + fn test_parse_hyperlink_literal_returns_none_when_unambiguous() { + // No percent escapes: identical to `parse_hyperlink`. + assert_eq!( + MentionUri::parse_hyperlink_literal("/tmp/a b.rs", PathStyle::Posix), + None + ); + // Invalid escape sequences are also left alone by `parse_hyperlink`. + assert_eq!( + MentionUri::parse_hyperlink_literal("/tmp/100%_done.txt", PathStyle::Posix), + None + ); + // Separator escapes are never decoded, so they're not ambiguous. + assert_eq!( + MentionUri::parse_hyperlink_literal("/tmp/a%2Fb.rs", PathStyle::Posix), + None + ); + // URLs are spec-encoded, not ambiguous. + assert_eq!( + MentionUri::parse_hyperlink_literal("file:///tmp/a%20b.rs", PathStyle::Posix), + None + ); + // Relative paths are not bare-path mentions. + assert_eq!( + MentionUri::parse_hyperlink_literal("tmp/a%20b.rs", PathStyle::Posix), + None + ); + } + #[test] fn test_to_directory_uri_without_slash() { let uri = MentionUri::Directory { diff --git a/crates/agent/src/tools/find_path_tool.rs b/crates/agent/src/tools/find_path_tool.rs index 481f1433fbf..eb08000d3a8 100644 --- a/crates/agent/src/tools/find_path_tool.rs +++ b/crates/agent/src/tools/find_path_tool.rs @@ -1,4 +1,5 @@ use crate::{AgentTool, ToolCallEventStream, ToolInput}; +use acp_thread::MentionUri; use agent_client_protocol::schema::v1 as acp; use anyhow::{Result, anyhow}; use futures::FutureExt as _; @@ -155,10 +156,13 @@ impl AgentTool for FindPathTool { paginated_matches .iter() .map(|path| { + let uri = MentionUri::File { + abs_path: path.clone(), + }; acp::ToolCallContent::Content(acp::Content::new( acp::ContentBlock::ResourceLink(acp::ResourceLink::new( path.to_string_lossy(), - format!("file://{}", path.display()), + uri.to_uri().to_string(), )), )) }) diff --git a/crates/agent/src/tools/grep_tool.rs b/crates/agent/src/tools/grep_tool.rs index 748642b3cda..5af92b2e451 100644 --- a/crates/agent/src/tools/grep_tool.rs +++ b/crates/agent/src/tools/grep_tool.rs @@ -1,4 +1,5 @@ use crate::{AgentTool, ToolCallEventStream, ToolInput}; +use acp_thread::MentionUri; use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use futures::{FutureExt as _, StreamExt}; @@ -327,10 +328,15 @@ impl AgentTool for GrepTool { output.push_str("\n```\n"); if let Some(abs_path) = &abs_path { + let uri = MentionUri::Selection { + abs_path: Some(abs_path.clone()), + line_range: range.start.row..=end_row, + column: None, + }; content.push(acp::ToolCallContent::Content(acp::Content::new( acp::ContentBlock::ResourceLink(acp::ResourceLink::new( format!("{}#{}", path.display(), line_label), - format!("file://{}#{}", abs_path.display(), line_label), + uri.to_uri().to_string(), )), ))); locations.push( @@ -393,6 +399,7 @@ mod tests { use project::{FakeFs, Project}; use serde_json::json; use settings::SettingsStore; + use std::path::PathBuf; use unindent::Unindent; use util::path; @@ -611,21 +618,30 @@ mod tests { .collect::>(); assert_eq!(links.len(), 2, "expected one resource link per match"); - let alpha_uri = format!("file://{}#L1", path!("/root/src/alpha.txt")); + let selection_uri = |abs_path: &str| { + MentionUri::Selection { + abs_path: Some(PathBuf::from(abs_path)), + line_range: 0..=0, + column: None, + } + .to_uri() + .to_string() + }; + + let alpha_uri = selection_uri(path!("/root/src/alpha.txt")); assert!( links.iter().any(|link| { - link.name.replace('\\', "/") == "root/src/alpha.txt#L1" - && link.uri.replace('\\', "/") == alpha_uri.replace('\\', "/") + link.name.replace('\\', "/") == "root/src/alpha.txt#L1" && link.uri == alpha_uri }), "missing clickable link for alpha.txt, got: {links:?}" ); - let beta_uri = format!("file://{}#L1", path!("/root/beta.txt")); + let beta_uri = selection_uri(path!("/root/beta.txt")); assert!( - links.iter().any(|link| { - link.name.replace('\\', "/") == "root/beta.txt#L1" - && link.uri.replace('\\', "/") == beta_uri.replace('\\', "/") - }), + links + .iter() + .any(|link| link.name.replace('\\', "/") == "root/beta.txt#L1" + && link.uri == beta_uri), "missing clickable link for beta.txt, got: {links:?}" ); diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 1949a5965a8..ccaf59f72b0 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -65,7 +65,7 @@ use serde::{Deserialize, Serialize}; use settings::{LanguageModelSelection, Settings as _, SettingsStore, SidebarSide}; use std::any::TypeId; use std::path::{Path, PathBuf}; -use workspace::Workspace; +use workspace::{OpenOptions, Workspace}; use crate::agent_configuration::ManageProfilesModal; pub use crate::agent_connection_store::{ActiveAcpConnection, AgentConnectionStore}; @@ -118,40 +118,68 @@ pub(crate) fn resolve_agent_image( None } +/// Opens `abs_path` in the workspace, moving the cursor to `point` when one +/// is given. Paths outside every worktree are only opened when a file exists +/// there, so broken agent links don't create empty buffers. pub(crate) fn open_abs_path_at_point( workspace: &mut Workspace, abs_path: PathBuf, - point: Point, + point: Option, window: &mut Window, cx: &mut Context, -) -> bool { - let project = workspace.project(); - let Some(path) = project.update(cx, |project, cx| project.find_project_path(abs_path, cx)) - else { - return false; - }; - - let item = workspace.open_path(path, None, true, window, cx); +) { + let project_path = workspace + .project() + .update(cx, |project, cx| project.find_project_path(&abs_path, cx)); + let fs = workspace.project().read(cx).fs().clone(); + let workspace = cx.weak_entity(); window .spawn(cx, async move |cx| { - let Some(editor) = item.await?.downcast::() else { + let item = if let Some(project_path) = project_path { + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path(project_path, None, true, window, cx) + })? + .await? + } else { + let metadata = fs.metadata(&abs_path).await?; + anyhow::ensure!( + metadata.is_some_and(|metadata| !metadata.is_dir), + "no file found at path {abs_path:?}" + ); + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_abs_path( + abs_path, + OpenOptions { + focus: Some(true), + ..Default::default() + }, + window, + cx, + ) + })? + .await? + }; + let Some(point) = point else { + return Ok(()); + }; + let Some(editor) = item.downcast::() else { return Ok(()); }; - let range = point..point; editor .update_in(cx, |editor, window, cx| { editor.change_selections( SelectionEffects::scroll(Autoscroll::center()), window, cx, - |selections| selections.select_ranges([range]), + |selections| selections.select_ranges([point..point]), ); }) .ok(); anyhow::Ok(()) }) .detach_and_log_err(cx); - true } pub const DEFAULT_THREAD_TITLE: &str = "New Agent Thread"; diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 42a22219883..2d7c6b6ca40 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -11931,19 +11931,31 @@ pub(crate) fn open_link( return; }; - if let Some(mention) = MentionUri::parse(&url, workspace.read(cx).path_style(cx)).log_err() { + let path_style = workspace.read(cx).path_style(cx); + if let Some(mention) = MentionUri::parse_hyperlink(&url, path_style).log_err() { + // Percent escapes in bare paths are ambiguous: prefer the decoded + // interpretation, falling back to the literal one (e.g. a file + // actually named `a%20b.rs`) only when the decoded path doesn't + // resolve in the project but the literal one does. + let resolves_in_project = |mention: &MentionUri, cx: &App| { + mention.abs_path().is_some_and(|abs_path| { + let project = workspace.read(cx).project().read(cx); + project + .find_project_path(abs_path, cx) + .is_some_and(|path| project.entry_for_path(&path, cx).is_some()) + }) + }; + let mention = match MentionUri::parse_hyperlink_literal(&url, path_style) { + Some(literal) + if !resolves_in_project(&mention, cx) && resolves_in_project(&literal, cx) => + { + literal + } + _ => mention, + }; workspace.update(cx, |workspace, cx| match mention { MentionUri::File { abs_path } => { - let project = workspace.project(); - let Some(path) = - project.update(cx, |project, cx| project.find_project_path(abs_path, cx)) - else { - return; - }; - - workspace - .open_path(path, None, true, window, cx) - .detach_and_log_err(cx); + open_abs_path_at_point(workspace, abs_path, None, window, cx); } MentionUri::PastedImage { .. } => {} MentionUri::Directory { abs_path } => { @@ -11967,7 +11979,7 @@ pub(crate) fn open_link( open_abs_path_at_point( workspace, path, - Point::new(*line_range.start(), 0), + Some(Point::new(*line_range.start(), 0)), window, cx, ); @@ -11980,7 +11992,7 @@ pub(crate) fn open_link( open_abs_path_at_point( workspace, path, - Point::new(*line_range.start(), column.unwrap_or(0)), + Some(Point::new(*line_range.start(), column.unwrap_or(0))), window, cx, ); @@ -12067,6 +12079,7 @@ mod tests { use super::*; use project::{FakeFs, Project}; use serde_json::json; + use std::path::Path; use util::path; use workspace::MultiWorkspace; @@ -12171,6 +12184,118 @@ mod tests { assert!(*active.path == *"src/main.rs"); }); } + + #[gpui::test] + async fn test_open_link_percent_escape_disambiguation(cx: &mut gpui::TestAppContext) { + crate::test_support::init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "a%20b.rs": "literal", + "a b.rs": "decoded", + "c d.rs": "", + "e%20f.rs": "", + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let workspace_weak = workspace.downgrade(); + + let open_link_and_active_path = |url: String, cx: &mut gpui::VisualTestContext| { + multi_workspace.update_in(cx, |_, window, cx| { + open_link(url.into(), &workspace_weak, window, cx); + }); + cx.run_until_parked(); + workspace.read_with(cx, |workspace, cx| { + workspace + .active_item(cx) + .and_then(|item| item.project_path(cx)) + .expect("file should be open") + .path + }) + }; + + // Both interpretations exist: the decoded one wins. + let path = open_link_and_active_path(path!("/project/a%20b.rs").to_string(), cx); + assert_eq!(*path, *"a b.rs"); + + // Only the decoded file exists. + let path = open_link_and_active_path(path!("/project/c%20d.rs").to_string(), cx); + assert_eq!(*path, *"c d.rs"); + + // Only the literally-named file exists: fall back to it. + let path = open_link_and_active_path(path!("/project/e%20f.rs").to_string(), cx); + assert_eq!(*path, *"e%20f.rs"); + } + + #[gpui::test] + async fn test_open_link_out_of_project_path(cx: &mut gpui::TestAppContext) { + crate::test_support::init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/project"), json!({"src": {"main.rs": ""}})) + .await; + fs.insert_tree(path!("/outside"), json!({"notes.md": "one\ntwo\nthree\n"})) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let workspace_weak = workspace.downgrade(); + + // A nonexistent out-of-project path opens nothing, not even an + // empty buffer. + multi_workspace.update_in(cx, |_, window, cx| { + open_link( + path!("/outside/missing.md").to_string().into(), + &workspace_weak, + window, + cx, + ); + }); + cx.run_until_parked(); + workspace.read_with(cx, |workspace, cx| { + assert!( + workspace.active_item(cx).is_none(), + "nothing should open for a nonexistent path" + ); + }); + + // An existing out-of-project file opens at the linked line. + multi_workspace.update_in(cx, |_, window, cx| { + open_link( + format!("{}:2", path!("/outside/notes.md")).into(), + &workspace_weak, + window, + cx, + ); + }); + cx.run_until_parked(); + let editor = workspace.read_with(cx, |workspace, cx| { + let item = workspace.active_item(cx).expect("file should be open"); + let project_path = item.project_path(cx).expect("item should have a path"); + let abs_path = workspace + .project() + .read(cx) + .absolute_path(&project_path, cx); + assert_eq!( + abs_path.as_deref(), + Some(Path::new(path!("/outside/notes.md"))) + ); + item.downcast::().expect("should be an editor") + }); + editor.update_in(cx, |editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + assert_eq!(editor.selections.newest::(&snapshot).head().row, 1); + }); + } } const FAST_MODE_WARNING_NAMESPACE: &str = "fast-mode-warning-dismissed"; diff --git a/crates/agent_ui/src/ui/mention_crease.rs b/crates/agent_ui/src/ui/mention_crease.rs index 97f45526cdc..6959b5bc4c0 100644 --- a/crates/agent_ui/src/ui/mention_crease.rs +++ b/crates/agent_ui/src/ui/mention_crease.rs @@ -160,14 +160,14 @@ fn open_mention_uri( workspace.update(cx, |workspace, cx| match mention_uri { MentionUri::File { abs_path } => { - open_file(workspace, abs_path, None, window, cx); + open_abs_path_at_point(workspace, abs_path, None, window, cx); } MentionUri::Symbol { abs_path, line_range, .. } => { - open_file( + open_abs_path_at_point( workspace, abs_path, Some(Point::new(*line_range.start(), 0)), @@ -180,7 +180,7 @@ fn open_mention_uri( line_range, column, } => { - open_file( + open_abs_path_at_point( workspace, abs_path, Some(Point::new(*line_range.start(), column.unwrap_or(0))), @@ -339,41 +339,6 @@ fn open_skill_content_buffer( workspace.add_item(pane, Box::new(editor), None, true, true, window, cx); } -fn open_file( - workspace: &mut Workspace, - abs_path: PathBuf, - point: Option, - window: &mut Window, - cx: &mut Context, -) { - if let Some(point) = point { - if open_abs_path_at_point(workspace, abs_path.clone(), point, window, cx) { - return; - } - } - - let project = workspace.project(); - if let Some(project_path) = - project.update(cx, |project, cx| project.find_project_path(&abs_path, cx)) - { - workspace - .open_path(project_path, None, true, window, cx) - .detach_and_log_err(cx); - } else if abs_path.exists() { - workspace - .open_abs_path( - abs_path, - OpenOptions { - focus: Some(true), - ..Default::default() - }, - window, - cx, - ) - .detach_and_log_err(cx); - } -} - fn reveal_in_project_panel( workspace: &mut Workspace, abs_path: PathBuf, From 5b805ac0743660a7034c6504b49a9f10f65524c0 Mon Sep 17 00:00:00 2001 From: Danny Clarke Date: Sun, 5 Jul 2026 18:58:57 -0700 Subject: [PATCH 072/197] git_graph: Selectable commit message in detail panel (#59674) # Objective Fixes #57766 Allow users to select and copy the text of commit messages from the git graph details panel. ## Solution Implements selectable commit message in the detail panel using `MarkdownElement`s a la `commit_view`. ### Affordances considered and abandoned: - "copy message" and/or "copy subject" buttons, but the UI here is already pretty tight and I don't feel confident enough in my design chops or familiarity with Zed to propose bigger UI changes here. - Default-collapsed message with expand button, a la `commit_view`. The use-case of this panel is to get the gist of a commit while moving somewhat quickly through the graph. One might argue that the subject line alone is enough for that, but often it isn't and the thought of having to constantly expand commit messages rather than making the message immediately visible with scroll felt like it would produce a repetitious experience. ## Testing - Adds two unit tests to verify that selection works and is idempotent ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase Video demonstrating the functionality: https://github.com/user-attachments/assets/b910aaa3-2db2-4da8-904b-21b270677ca9 --- Release Notes: - Show full commit message as markdown in details panel of the git graph --- crates/git_ui/src/git_graph.rs | 316 ++++++++++++++++++++++++++++++++- 1 file changed, 307 insertions(+), 9 deletions(-) diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index 384474e2105..9366039969e 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -19,12 +19,13 @@ use git::{ use gpui::{ Action, Anchor, AnyElement, App, Bounds, ClickEvent, ClipboardItem, DefiniteLength, DismissEvent, DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, - Hsla, MouseButton, MouseDownEvent, PathBuilder, Pixels, Point, ScrollStrategy, + Hsla, MouseButton, MouseDownEvent, PathBuilder, Pixels, Point, ScrollHandle, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task, TextStyleRefinement, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, point, prelude::*, px, uniform_list, }; use language::line_diff; +use markdown::{Markdown, MarkdownElement}; use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use picker::{Picker, PickerDelegate}; use project::{ @@ -57,6 +58,7 @@ use ui::{ prelude::*, render_redistributable_columns_resize_handles, render_table_header, table_row::TableRow, }; +use util::{ResultExt, debug_panic}; use workspace::{ ModalView, Workspace, item::{Item, ItemEvent, TabTooltipContent}, @@ -1322,6 +1324,12 @@ struct GitGraphContextMenu { _subscription: Subscription, } +struct DetailPanelCommitMessage { + sha: Oid, + message: Entity, + scroll_handle: ScrollHandle, +} + pub struct GitGraph { focus_handle: FocusHandle, search_state: SearchState, @@ -1339,6 +1347,8 @@ pub struct GitGraph { selected_commit_diff: Option, selected_commit_diff_stats: Option<(usize, usize)>, _commit_diff_task: Option>, + selected_commit_message: Option, + _selected_commit_message_task: Option>, commit_details_split_state: Entity, repo_id: RepositoryId, changed_files_scroll_handle: UniformListScrollHandle, @@ -1559,6 +1569,8 @@ impl GitGraph { graph_canvas_bounds: Rc::new(Cell::new(None)), selected_commit_diff: None, selected_commit_diff_stats: None, + selected_commit_message: None, + _selected_commit_message_task: None, log_source, log_order, commit_details_split_state: cx.new(|_cx| SplitState::new()), @@ -2179,13 +2191,16 @@ impl GitGraph { return; }; - let sha = commit.data.sha.to_string(); - let Some(repository) = self.get_repository(cx) else { return; }; - let diff_receiver = repository.update(cx, |repo, _| repo.load_commit_diff(sha)); + let commit_message_handle = commit.data.sha; + let diff_handle = commit.data.sha.to_string(); + + self.load_selected_commit_message(cx, &commit_message_handle, &repository); + + let diff_receiver = repository.update(cx, |repo, _| repo.load_commit_diff(diff_handle)); self._commit_diff_task = Some(cx.spawn(async move |this, cx| { if let Ok(Ok(diff)) = diff_receiver.await { @@ -2203,6 +2218,70 @@ impl GitGraph { cx.notify(); } + fn load_selected_commit_message( + &mut self, + cx: &mut Context<'_, Self>, + sha: &Oid, + repository: &Entity, + ) { + if self + .selected_commit_message + .as_ref() + .is_some_and(|old| old.sha == *sha) + { + return; + } + + self._selected_commit_message_task = None; + match repository.update(cx, |repo, cx| { + repo.fetch_commit_data(*sha, true, cx).clone() + }) { + CommitDataState::Loaded(commit_data) => { + self.set_selected_commit_message(cx, commit_data.sha, commit_data.message.clone()); + } + CommitDataState::Loading(Some(receiver)) => { + self._selected_commit_message_task = Some(cx.spawn(async move |this, cx| { + if let Ok(commit_data) = receiver.await { + this.update(cx, |this, cx| { + this.set_selected_commit_message( + cx, + commit_data.sha, + commit_data.message.clone(), + ); + }) + .log_err(); + } + })) + } + _ => { + debug_panic!( + "Fetched commit data asynchronously, but was not given a listener or cached commit data." + ); + } + }; + } + + fn set_selected_commit_message( + &mut self, + cx: &mut Context<'_, GitGraph>, + sha: Oid, + message: SharedString, + ) { + let languages = self + .workspace + .read_with(cx, |workspace, cx| { + workspace.project().read(cx).languages().clone() + }) + .log_err(); + self.selected_commit_message = Some(DetailPanelCommitMessage { + sha, + message: cx.new(|cx| Markdown::new(message, languages, None, cx)), + scroll_handle: ScrollHandle::new(), + }); + self._selected_commit_message_task = None; + cx.notify(); + } + fn select_previous_match(&mut self, cx: &mut Context) { if self.search_state.matches.is_empty() { return; @@ -2815,15 +2894,13 @@ impl GitGraph { .copied() .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default()); - // todo(git graph): We should use the full commit message here - let (author_name, author_email, commit_timestamp, commit_message) = match &data { + let (author_name, author_email, commit_timestamp) = match &data { CommitDataState::Loaded(data) => ( data.author_name.clone(), data.author_email.clone(), Some(data.commit_timestamp), - data.subject.clone(), ), - CommitDataState::Loading(_) => ("Loading…".into(), "".into(), None, "Loading…".into()), + CommitDataState::Loading(_) => ("Loading…".into(), "".into(), None), }; let date_string = commit_timestamp @@ -2917,6 +2994,8 @@ impl GitGraph { this.selected_entry_idx = None; this.selected_commit_diff = None; this.selected_commit_diff_stats = None; + this.selected_commit_message = None; + this._selected_commit_message_task = None; this.changed_files_expanded_dirs.clear(); this._commit_diff_task = None; cx.notify(); @@ -3091,7 +3170,7 @@ impl GitGraph { ), ) .child(Divider::horizontal()) - .child(div().p_2().child(Label::new(commit_message))) + .child(self.render_commit_message(window, cx)) .child(Divider::horizontal()) .child( v_flex() @@ -3723,6 +3802,53 @@ impl GitGraph { ) .into_any_element() } + + fn render_commit_message( + &self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let Some(DetailPanelCommitMessage { + message, + scroll_handle, + .. + }) = self.selected_commit_message.as_ref() + else { + return Empty.into_any_element(); + }; + + let message_style = editor::hover_markdown_style(window, cx); + let rem_size = window.rem_size(); + let line_height = message_style + .base_text_style + .line_height_in_pixels(rem_size); + + div() + // Using grid over flexbox because the structure of this side + // panel prvents taffy from calculating a concrete width correctly, + // which causes problems with text reflow when using flexbox. + // grid, on the other hand, doesn't appear to give taffy the same + // problems. + .grid() + .py_2() + .pl_2() + .w_full() + .gap_1() + .grid_cols(1) + // Value of 12 taken from ./commit_view.rs:725 + .max_h(line_height * 12.) + .child( + div() + .id("commit-message") + .text_sm() + .size_full() + .overflow_y_scroll() + .track_scroll(scroll_handle) + .child(MarkdownElement::new(message.clone(), message_style)) + .vertical_scrollbar_for(scroll_handle, window, cx), + ) + .into_any_element() + } } impl Render for GitGraph { @@ -7032,4 +7158,176 @@ mod tests { ); assert_eq!(GitGraph::ref_name_from_decoration("HEAD"), None); } + + #[gpui::test] + async fn test_commit_message_rendered_as_markdown(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new("/project"), + json!({ ".git": {}, "file.txt": "content" }), + ) + .await; + + let commit_sha = Oid::from_bytes(&[1; 20]).unwrap(); + let commits = vec![Arc::new(InitialGraphCommitData { + sha: commit_sha, + parents: smallvec![], + ref_names: vec!["HEAD -> main".into()], + })]; + fs.set_graph_commits(Path::new("/project/.git"), commits); + fs.set_commit_data( + Path::new("/project/.git"), + [( + CommitData { + sha: commit_sha, + parents: smallvec![], + author_name: "Author".into(), + author_email: "author@example.com".into(), + commit_timestamp: 1_700_000_000, + subject: "Fix crash".into(), + message: "Fix crash\n\nThis fixes a crash that occurred when...".into(), + }, + false, + )], + ); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + cx.run_until_parked(); + + let repository = project.read_with(cx, |project, cx| { + project + .active_repository(cx) + .expect("should have a repository") + }); + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace_weak = + multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade()); + + let git_graph = cx.new_window_entity(|window, cx| { + GitGraph::new( + repository.read(cx).id, + project.read(cx).git_store().clone(), + workspace_weak, + None, + window, + cx, + ) + }); + cx.run_until_parked(); + + // Select the commit to trigger loading the commit message + git_graph.update_in(cx, |graph, window, cx| { + graph.select_first(&menu::SelectFirst, window, cx); + }); + cx.run_until_parked(); + + // Verify the commit message was loaded as markdown + git_graph.read_with(&*cx, |graph, app| { + let message = graph + .selected_commit_message + .as_ref() + .expect("selected_commit_message should be Some"); + assert_eq!(message.sha, commit_sha); + let source = message.message.read_with(app, |m, _| m.source().to_owned()); + assert!(source.contains("Fix crash")); + assert!(source.contains("This fixes a crash")); + }); + } + + #[gpui::test] + async fn test_commit_message_not_reloaded_for_same_sha(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new("/project"), + json!({ ".git": {}, "file.txt": "content" }), + ) + .await; + + let commit_sha = Oid::from_bytes(&[1; 20]).unwrap(); + let commits = vec![Arc::new(InitialGraphCommitData { + sha: commit_sha, + parents: smallvec![], + ref_names: vec!["HEAD -> main".into()], + })]; + fs.set_graph_commits(Path::new("/project/.git"), commits); + fs.set_commit_data( + Path::new("/project/.git"), + [( + CommitData { + sha: commit_sha, + parents: smallvec![], + author_name: "Author".into(), + author_email: "author@example.com".into(), + commit_timestamp: 1_700_000_000, + subject: "Fix crash".into(), + message: "Fix crash\n\nBody text.".into(), + }, + false, + )], + ); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + cx.run_until_parked(); + + let repository = project.read_with(cx, |project, cx| { + project + .active_repository(cx) + .expect("should have a repository") + }); + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace_weak = + multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade()); + + let git_graph = cx.new_window_entity(|window, cx| { + GitGraph::new( + repository.read(cx).id, + project.read(cx).git_store().clone(), + workspace_weak, + None, + window, + cx, + ) + }); + cx.run_until_parked(); + + // Select the commit to load the message + git_graph.update_in(cx, |graph, window, cx| { + graph.select_first(&menu::SelectFirst, window, cx); + }); + cx.run_until_parked(); + + // Verify message is loaded + let message_entity_id = git_graph.read_with(&*cx, |graph, _| { + graph + .selected_commit_message + .as_ref() + .map(|m| m.message.entity_id()) + }); + assert!(message_entity_id.is_some()); + + // Select the same commit again to trigger the early-return logic + git_graph.update_in(cx, |graph, window, cx| { + graph.select_first(&menu::SelectFirst, window, cx); + }); + cx.run_until_parked(); + + // Verify the message entity is the same (not replaced) + git_graph.read_with(&*cx, |graph, _| { + let new_entity_id = graph + .selected_commit_message + .as_ref() + .map(|m| m.message.entity_id()); + assert_eq!(message_entity_id, new_entity_id); + }); + } } From b4d9194fce5da4d23a558684adc045c8dcf9a620 Mon Sep 17 00:00:00 2001 From: Remco Smits Date: Mon, 6 Jul 2026 05:12:43 +0200 Subject: [PATCH 073/197] themes: Don't try to call `fs.load_bytes` on a directory path on rescans (#60399) # Objective Before this change, we watched the themes directory itself. During rescans, this could generate events for the directory, causing Zed to attempt to load `event.path` as a file via `fs.load_bytes(&event.path).await.log_err()`. This happened because `fs.metadata(&event.path).await.ok().flatten().is_some()` also returns true for directories. Since load_bytes will always fail for a directory, we can avoid the unnecessary call by skipping it whenever `event.path` refers to a directory. The rescan happens when Zed lost sync with the filesystem. That will produce the following logs: ``` 2026-07-01T16:03:35+02:00 WARN [fs::fs_watcher] filesystem watcher lost sync for many files, not logging more 2026-07-01T16:03:35+02:00 ERROR [crates/zed/src/main.rs:1891] Is a directory (os error 21) ``` ## Solution The solution is that we just can check if the `event.path` is a directory if so we can skip the `fs.load_bytes(&event.path).await.log_err()` part described aboth. ## Testing All tests should still pass and there should be no impact since this only happens on rescans when the system is under pressure with alot of fs events. **Note**: No regression test was added since there's nothing to test here. ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - N/A --- crates/zed/src/main.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 725db34126c..1dcac5480f5 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -1886,7 +1886,13 @@ fn watch_themes(fs: Arc, cx: &mut App) { while let Some(paths) = events.next().await { for event in paths { - if fs.metadata(&event.path).await.ok().flatten().is_some() { + if fs + .metadata(&event.path) + .await + .ok() + .flatten() + .is_some_and(|m| !m.is_dir) + { let theme_registry = cx.update(|cx| ThemeRegistry::global(cx)); if let Some(bytes) = fs.load_bytes(&event.path).await.log_err() && load_user_theme(&theme_registry, &bytes).log_err().is_some() From 04de6dab7c890d815e316f2f9554f9eeeaf8ebb2 Mon Sep 17 00:00:00 2001 From: Eagl61 <17625015+Eagl61@users.noreply.github.com> Date: Mon, 6 Jul 2026 05:15:43 +0200 Subject: [PATCH 074/197] Show type-changed files in commit diffs (#60422) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective Zed ignores files marked by Git as type-changed (T), causing commits containing only these changes to show 0 Changed Files. For example, commit d7cc949e61351a6028f933abbaea39f67af9dfb2 changes crates/eval_utils/LICENSE-GPL from a regular file to a symlink, but Zed displays no changes. ## Solution Handle TypeChanged files like modified files by loading both their old and new contents. ## Testing - Added parser coverage for the T status. - Added a repository test for a regular-file-to-symlink change. - Ran cargo test -p git. - Ran ./script/clippy -p git. - Manually verified the example commit on macOS. ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed- industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase Before: image After: image ——— Release Notes: - Fixed type-changed files not appearing in Git Graph and commit views. --- crates/git/src/commit.rs | 3 ++ crates/git/src/repository.rs | 59 ++++++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/crates/git/src/commit.rs b/crates/git/src/commit.rs index 50b62fa506b..326c741ad19 100644 --- a/crates/git/src/commit.rs +++ b/crates/git/src/commit.rs @@ -109,6 +109,7 @@ pub fn parse_git_diff_name_status(content: &str) -> impl Iterator StatusCode::Modified, + "T" => StatusCode::TypeChanged, "A" => StatusCode::Added, "D" => StatusCode::Deleted, _ => continue, @@ -127,6 +128,7 @@ mod tests { fn test_parse_git_diff_name_status() { let input = concat!( "M\x00Cargo.lock\x00", + "T\x00LICENSE-GPL\x00", "M\x00crates/project/Cargo.toml\x00", "M\x00crates/project/src/buffer_store.rs\x00", "D\x00crates/project/src/git.rs\x00", @@ -142,6 +144,7 @@ mod tests { output, &[ ("Cargo.lock", StatusCode::Modified), + ("LICENSE-GPL", StatusCode::TypeChanged), ("crates/project/Cargo.toml", StatusCode::Modified), ("crates/project/src/buffer_store.rs", StatusCode::Modified), ("crates/project/src/git.rs", StatusCode::Deleted), diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index f09c13f54ab..2951a84b090 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1445,7 +1445,7 @@ impl GitRepository for RealGitRepository { }; match status_code { - StatusCode::Modified => { + StatusCode::Modified | StatusCode::TypeChanged => { stdin.write_all(commit.as_bytes()).await?; stdin.write_all(b":").await?; stdin.write_all(path.as_bytes()).await?; @@ -1491,7 +1491,7 @@ impl GitRepository for RealGitRepository { }; match status_code { - StatusCode::Modified => { + StatusCode::Modified | StatusCode::TypeChanged => { info_line.clear(); stdout.read_line(&mut info_line).await?; let len = info_line.trim_end().parse().with_context(|| { @@ -4031,6 +4031,61 @@ mod tests { ); } + #[gpui::test] + async fn test_load_commit_with_type_changed_file(cx: &mut TestAppContext) { + disable_git_global_config(); + cx.executor().allow_parking(); + + let repo_dir = tempfile::tempdir().expect("failed to create temporary repository"); + git_init_repo(repo_dir.path()); + fs::write(repo_dir.path().join("file.txt"), "regular contents\n") + .expect("failed to write regular file"); + git_command(repo_dir.path(), ["add", "file.txt"]); + git_command(repo_dir.path(), ["commit", "-m", "initial"]); + + let repository = RealGitRepository::new( + &repo_dir.path().join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .expect("failed to open repository"); + fs::write(repo_dir.path().join("file.txt"), "target") + .expect("failed to write symlink target"); + + let symlink_blob = repository + .git_binary() + .run(&["hash-object", "-w", "file.txt"]) + .await + .expect("failed to write symlink blob"); + git_command( + repo_dir.path(), + [ + OsString::from("update-index"), + OsString::from("--cacheinfo"), + OsString::from("120000"), + OsString::from(symlink_blob), + OsString::from("file.txt"), + ], + ); + git_command(repo_dir.path(), ["commit", "-m", "type change"]); + + let commit_diff = repository + .load_commit("HEAD".to_string(), cx.to_async()) + .await + .expect("failed to load type-changed commit"); + assert_eq!(commit_diff.files.len(), 1); + + let file = commit_diff + .files + .first() + .expect("type-changed file should be present"); + assert_eq!(file.path.as_unix_str(), "file.txt"); + assert_eq!(file.old_text.as_deref(), Some("regular contents\n")); + assert_eq!(file.new_text.as_deref(), Some("target")); + assert_eq!(file.status(), CommitFileStatus::Modified); + } + #[gpui::test] async fn test_check_access(cx: &mut TestAppContext) { disable_git_global_config(); From 24c5b37e6e4952faf3145f10a97b3806aabfcb17 Mon Sep 17 00:00:00 2001 From: Anthony Gregis Date: Mon, 6 Jul 2026 00:49:03 -0500 Subject: [PATCH 075/197] Filter AI keybindings when AI features are disabled (#56936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `disable_ai` is set, bindings to AI-namespaced actions (assistant, agent, inline_assistant, acp, edit_prediction, zeta) are now dropped at keymap load time. Previously the bindings stayed active and their handlers silently no-op'd, which shadowed lower-precedence editor defaults — pressing ctrl-enter in an editor was captured by `assistant::InlineAssist` and did nothing instead of falling through to `editor::NewlineBelow`. The filter applies to both default and user bindings, and a settings observer reloads the keymap when `disable_ai` toggles at runtime. 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 #56692 Release Notes: - Fixed AI keybindings remaining active and shadowing editor defaults when AI features are disabled --------- Co-authored-by: Chris Biscardi --- crates/zed/src/zed.rs | 113 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 107 insertions(+), 6 deletions(-) diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 22ba7cee3d4..69fc69d5469 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -2037,19 +2037,23 @@ pub fn handle_keymap_file_changes( let mut old_base_keymap = *BaseKeymap::get_global(cx); let mut old_vim_enabled = VimModeSetting::get_global(cx).0; let mut old_helix_enabled = vim_mode_setting::HelixModeSetting::get_global(cx).0; + let mut old_disable_ai = DisableAiSettings::get_global(cx).disable_ai; cx.observe_global::(move |cx| { let new_base_keymap = *BaseKeymap::get_global(cx); let new_vim_enabled = VimModeSetting::get_global(cx).0; let new_helix_enabled = vim_mode_setting::HelixModeSetting::get_global(cx).0; + let new_disable_ai = DisableAiSettings::get_global(cx).disable_ai; if new_base_keymap != old_base_keymap || new_vim_enabled != old_vim_enabled || new_helix_enabled != old_helix_enabled + || new_disable_ai != old_disable_ai { old_base_keymap = new_base_keymap; old_vim_enabled = new_vim_enabled; old_helix_enabled = new_helix_enabled; + old_disable_ai = new_disable_ai; base_keymap_tx.unbounded_send(()).unwrap(); } @@ -2218,7 +2222,7 @@ fn reload_keymaps(cx: &mut App, mut user_key_bindings: Vec) { for key_binding in &mut user_key_bindings { key_binding.set_meta(KeybindSource::User.meta()); } - cx.bind_keys(user_key_bindings); + cx.bind_keys(filter_disabled_ai_bindings(user_key_bindings, cx)); let menus = app_menus(cx); cx.set_menus(menus); @@ -2238,18 +2242,23 @@ pub fn load_default_keymap(cx: &mut App) { return; } - cx.bind_keys( + cx.bind_keys(filter_disabled_ai_bindings( KeymapFile::load_asset(DEFAULT_KEYMAP_PATH, Some(KeybindSource::Default), cx).unwrap(), - ); + cx, + )); if let Some(asset_path) = base_keymap.asset_path() { - cx.bind_keys(KeymapFile::load_asset(asset_path, Some(KeybindSource::Base), cx).unwrap()); + cx.bind_keys(filter_disabled_ai_bindings( + KeymapFile::load_asset(asset_path, Some(KeybindSource::Base), cx).unwrap(), + cx, + )); } if VimModeSetting::get_global(cx).0 || vim_mode_setting::HelixModeSetting::get_global(cx).0 { - cx.bind_keys( + cx.bind_keys(filter_disabled_ai_bindings( KeymapFile::load_asset(VIM_KEYMAP_PATH, Some(KeybindSource::Vim), cx).unwrap(), - ); + cx, + )); } cx.bind_keys( @@ -2262,6 +2271,37 @@ pub fn load_default_keymap(cx: &mut App) { ); } +/// Namespaces of actions that are part of an AI feature. When the user opts out +/// of AI via the `disable_ai` setting, bindings to these actions are dropped so +/// that lower-precedence editor defaults (e.g. `editor::NewlineBelow` for +/// `ctrl-enter`) can fire instead of being shadowed by an action whose handler +/// silently no-ops. +const AI_ACTION_NAMESPACES: &[&str] = &[ + "acp::", + "agent::", + "assistant::", + "edit_prediction::", + "inline_assistant::", + "zeta::", +]; + +fn is_ai_keybinding(binding: &KeyBinding) -> bool { + let name = binding.action().name(); + AI_ACTION_NAMESPACES + .iter() + .any(|namespace| name.starts_with(namespace)) +} + +fn filter_disabled_ai_bindings(bindings: Vec, cx: &App) -> Vec { + if !DisableAiSettings::get_global(cx).disable_ai { + return bindings; + } + bindings + .into_iter() + .filter(|binding| !is_ai_keybinding(binding)) + .collect() +} + pub fn open_new_ssh_project_from_project( workspace: &mut Workspace, paths: Vec, @@ -5799,6 +5839,67 @@ mod tests { // If this panics, the test has failed } + #[gpui::test] + async fn test_disable_ai_filters_keybindings(cx: &mut gpui::TestAppContext) { + let _app_state = init_keymap_test(cx); + + // With AI enabled, the default keymap should include the assistant + // bindings that intercept e.g. ctrl-enter in the editor. + cx.update(load_default_keymap); + cx.update(|cx| { + let keymap = cx.key_bindings(); + let keymap = keymap.borrow(); + let has_ai_binding = keymap.bindings().any(|binding| is_ai_keybinding(binding)); + assert!( + has_ai_binding, + "expected AI-namespaced bindings in the default keymap before disabling AI" + ); + }); + + cx.update(|cx| { + SettingsStore::update_global(cx, |settings_store, cx| { + settings_store.update_user_settings(cx, |settings| { + settings.project.disable_ai = Some(SaturatingBool(true)); + }); + }); + }); + + // The default keymap should drop every AI-namespaced binding so that + // lower-precedence editor defaults can run instead. + cx.update(|cx| { + cx.clear_key_bindings(); + load_default_keymap(cx); + }); + cx.update(|cx| { + let keymap = cx.key_bindings(); + let keymap = keymap.borrow(); + if let Some(binding) = keymap.bindings().find(|b| is_ai_keybinding(b)) { + panic!( + "expected no AI-namespaced bindings after disabling AI, but found `{}`", + binding.action().name() + ); + } + }); + + // User-defined bindings to AI actions should also be filtered. + let user_binding = KeyBinding::new( + "ctrl-enter", + zed_actions::assistant::InlineAssist { prompt: None }, + None, + ); + cx.update(|cx| reload_keymaps(cx, vec![user_binding])); + cx.update(|cx| { + let keymap = cx.key_bindings(); + let keymap = keymap.borrow(); + if let Some(binding) = keymap.bindings().find(|b| is_ai_keybinding(b)) { + panic!( + "expected user binding `{}` to be filtered when AI is disabled", + binding.action().name() + ); + } + }); + } + #[gpui::test] async fn test_prefer_focused_window(cx: &mut gpui::TestAppContext) { let app_state = init_test(cx); From 62477092a20938d07de278555b1e99fd07b79b4f Mon Sep 17 00:00:00 2001 From: Sathwik Chirivelli <146921254+chirivelli@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:24:29 +0530 Subject: [PATCH 076/197] git_panel: Scope folder expansion state to sections (#60396) # Objective Prevent collapsing a folder in one Git panel section from also collapsing the same folder path in other sections. ## Solution Key directory expansion state by `TreeKey`, which includes both the section and repository path, instead of by `RepoPath` alone. This also keeps persisted tree state, stale-state cleanup, and selected-file reveal behavior section-aware. ## Testing - Added `test_tree_view_directory_expansion_is_scoped_to_section` to cover the same folder path under Tracked and Untracked independently. - `cargo fmt -p git_ui -- --check` - `git diff --check` - Attempted `cargo test -p git_ui test_tree_view_directory_expansion_is_scoped_to_section --lib`, but dependency compilation could not complete because the local disk ran out of space. Reviewers can run that command to execute the focused regression test. ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Suggested .rules additions - In the Git panel tree view, key per-row UI state by `TreeKey` rather than `RepoPath`, because the same directory can appear in multiple sections. --- Release Notes: - Fixed collapsing a folder in one Git panel section also collapsing it in other sections. --- crates/git_ui/src/git_panel.rs | 81 +++++++++++++++++++++++++++------- 1 file changed, 66 insertions(+), 15 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 6875ddea218..134aad4bc69 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -498,7 +498,7 @@ struct TreeViewState { // Length equals the number of visible entries. // This is needed because some entries (like collapsed directories) may be hidden. logical_indices: Vec, - expanded_dirs: HashMap, + expanded_dirs: HashMap, directory_descendants: HashMap>, } @@ -573,8 +573,8 @@ impl TreeViewState { let (child_flattened, mut child_statuses) = self.flatten_tree(terminal, section, depth + 1, seen_directories); let key = TreeKey { section, path }; - let expanded = *self.expanded_dirs.get(&key.path).unwrap_or(&true); - self.expanded_dirs.entry(key.path.clone()).or_insert(true); + let expanded = *self.expanded_dirs.get(&key).unwrap_or(&true); + self.expanded_dirs.entry(key.clone()).or_insert(true); seen_directories.insert(key.clone()); self.directory_descendants @@ -756,7 +756,7 @@ pub struct GitPanel { generate_commit_message_task: Option>>, entries: Vec, view_mode: GitPanelViewMode, - tree_expanded_dirs: HashMap, + tree_expanded_dirs: HashMap, entries_indices: HashMap, single_staged_entry: Option, single_tracked_entry: Option, @@ -1136,8 +1136,8 @@ impl GitPanel { path: RepoPath::from_rel_path(dir), }; - if tree_state.expanded_dirs.get(&key.path) == Some(&false) { - tree_state.expanded_dirs.insert(key.path.clone(), true); + if tree_state.expanded_dirs.get(&key) == Some(&false) { + tree_state.expanded_dirs.insert(key, true); needs_rebuild = true; } @@ -3940,7 +3940,7 @@ impl GitPanel { fn toggle_directory(&mut self, key: &TreeKey, window: &mut Window, cx: &mut Context) { if let Some(state) = self.view_mode.tree_state_mut() { - let expanded = state.expanded_dirs.entry(key.path.clone()).or_insert(true); + let expanded = state.expanded_dirs.entry(key.clone()).or_insert(true); *expanded = !*expanded; self.tree_expanded_dirs = state.expanded_dirs.clone(); self.update_visible_entries(window, cx); @@ -4353,13 +4353,9 @@ impl GitPanel { } } - let seen_directory_paths = seen_directories - .iter() - .map(|directory| directory.path.clone()) - .collect::>(); tree_state .expanded_dirs - .retain(|path, _| seen_directory_paths.contains(path)); + .retain(|key, _| seen_directories.contains(key)); self.tree_expanded_dirs = tree_state.expanded_dirs.clone(); self.view_mode = GitPanelViewMode::Tree(tree_state); } @@ -8092,6 +8088,61 @@ mod tests { }); } + #[test] + fn test_tree_view_directory_expansion_is_scoped_to_section() { + let entry = |path, status| GitStatusEntry { + repo_path: repo_path(path), + status, + staging: StageStatus::Unstaged, + diff_stat: None, + }; + let mut state = TreeViewState::default(); + let mut seen_directories = HashSet::default(); + + state.build_tree_entries( + Section::Tracked, + vec![entry("src/tracked.rs", StatusCode::Modified.worktree())], + &mut seen_directories, + ); + state.build_tree_entries( + Section::New, + vec![entry("src/new.rs", FileStatus::Untracked)], + &mut seen_directories, + ); + + let tracked_key = TreeKey { + section: Section::Tracked, + path: repo_path("src"), + }; + let new_key = TreeKey { + section: Section::New, + path: repo_path("src"), + }; + state.expanded_dirs.insert(tracked_key.clone(), false); + + let tracked_entries = state.build_tree_entries( + Section::Tracked, + vec![entry("src/tracked.rs", StatusCode::Modified.worktree())], + &mut seen_directories, + ); + let new_entries = state.build_tree_entries( + Section::New, + vec![entry("src/new.rs", FileStatus::Untracked)], + &mut seen_directories, + ); + + assert_eq!(state.expanded_dirs.get(&tracked_key), Some(&false)); + assert_eq!(state.expanded_dirs.get(&new_key), Some(&true)); + assert!(matches!( + tracked_entries.first(), + Some((GitListEntry::Directory(entry), _)) if !entry.expanded + )); + assert!(matches!( + new_entries.first(), + Some((GitListEntry::Directory(entry), _)) if entry.expanded + )); + } + fn register_git_commit_language(project: &Entity, cx: &mut VisualTestContext) { project.read_with(cx, |project, _| { project.languages().add(Arc::new(language::Language::new( @@ -9776,7 +9827,7 @@ mod tests { .view_mode .tree_state() .expect("tree view state should exist"); - assert_eq!(state.expanded_dirs.get(&src_key.path).copied(), Some(false)); + assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(false)); }); let worktree_id = @@ -9795,7 +9846,7 @@ mod tests { .view_mode .tree_state() .expect("tree view state should exist"); - assert_eq!(state.expanded_dirs.get(&src_key.path).copied(), Some(true)); + assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(true)); let selected_ix = panel.selected_entry.expect("selection should be set"); assert!(state.logical_indices.contains(&selected_ix)); @@ -9904,7 +9955,7 @@ mod tests { .view_mode .tree_state() .expect("tree view state should exist"); - assert_eq!(state.expanded_dirs.get(&foo_key.path).copied(), Some(false)); + assert_eq!(state.expanded_dirs.get(&foo_key).copied(), Some(false)); let foo_idx = panel .entries From 01568e5569b4952b721af8b545173d44c029baaa Mon Sep 17 00:00:00 2001 From: XiaoYan Li Date: Mon, 6 Jul 2026 16:10:13 +0800 Subject: [PATCH 077/197] Swap Kotlin LSPs in documentation to reflect actual default config (#54061) ## Summary - The default settings in `assets/settings/default.json` disable `kotlin-language-server` (prefixed with `!`) and enable `kotlin-lsp` as the primary LSP, but the Kotlin documentation listed them the other way around. This swaps the order so the docs match reality. ## Test plan - [x] Verified `assets/settings/default.json` has `["!kotlin-language-server", "kotlin-lsp", "..."]` for Kotlin - [x] Confirmed the documentation now lists `kotlin-lsp` as the primary Language Server and `kotlin-language-server` as the alternate Release Notes: - N/A --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Smit Barmase --- docs/src/languages/kotlin.md | 45 ++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/docs/src/languages/kotlin.md b/docs/src/languages/kotlin.md index 262dce5ac45..93f48980a70 100644 --- a/docs/src/languages/kotlin.md +++ b/docs/src/languages/kotlin.md @@ -9,10 +9,45 @@ Kotlin language support in Zed is provided by the community-maintained [Kotlin e Report issues to: [https://github.com/zed-extensions/kotlin/issues](https://github.com/zed-extensions/kotlin/issues) - Tree-sitter: [fwcd/tree-sitter-kotlin](https://github.com/fwcd/tree-sitter-kotlin) -- Language Server: [fwcd/kotlin-language-server](https://github.com/fwcd/kotlin-language-server) -- Alternate Language Server: [kotlin/kotlin-lsp](https://github.com/kotlin/kotlin-lsp) +- Language Server: [kotlin/kotlin-lsp](https://github.com/kotlin/kotlin-lsp) +- Alternate Language Server: [fwcd/kotlin-language-server](https://github.com/fwcd/kotlin-language-server) -## Configuration +## Kotlin LSP + +[Kotlin LSP](https://github.com/kotlin/kotlin-lsp) is the official language server for Kotlin, built by JetBrains. It is used by default. + +It is downloaded and updated automatically. If you want to use a manually installed version instead, set the path to the `kotlin-lsp.sh` script from the release assets in your `settings.json`: + +```json [settings] +{ + "lsp": { + "kotlin-lsp": { + "binary": { + "path": "path/to/kotlin-lsp.sh", + "arguments": ["--stdio"] + } + } + } +} +``` + +Note that the `kotlin-lsp.sh` script expects to be run from within the unzipped release zip file, and should not be moved elsewhere. + +## Kotlin Language Server + +The community-maintained [Kotlin Language Server](https://github.com/fwcd/kotlin-language-server) can be used instead of Kotlin LSP by explicitly enabling it in your `settings.json`: + +```json [settings] +{ + "languages": { + "Kotlin": { + "language_servers": ["kotlin-language-server", "!kotlin-lsp", "..."] + } + } +} +``` + +### Configuration Workspace configuration options can be passed to the language server via lsp settings in `settings.json`. @@ -21,7 +56,7 @@ The full list of lsp `settings` can be found [here](https://github.com/fwcd/kotlin-language-server/blob/main/server/src/main/kotlin/org/javacs/kt/Configuration.kt) under `class Configuration` and initialization_options under `class InitializationOptions`. -### JVM Target +#### JVM Target The following example changes the JVM target from `default` (which is 1.8) to `17`: @@ -42,7 +77,7 @@ The following example changes the JVM target from `default` (which is 1.8) to } ``` -### JAVA_HOME +#### JAVA_HOME To use a specific java installation, just specify the `JAVA_HOME` environment variable with: From 69664ab9d33590a524df1164a74767c4285f6086 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 6 Jul 2026 10:18:50 +0200 Subject: [PATCH 078/197] agent: Show errors when provider is not authenticated but model is configured (#60417) This PR improves the onboarding experience when using the agent panel. Previously we would pick a fallback model in case the provider failed to authenticate/was slow to resolve models. However, this code had a race condition (for providers that resolve models dynamically like Anthropic/GitHub Copilot), since we pick a fallback immediately after all providers have been authenticated. However, at that point the configured provider might not have resolved its models, so we would fall back to a different provider. At some point we added a workaround for the zed.dev provider. We landed on a much simpler approach that eliminates the race condition: We only pick a fallback model in case the user actually has no model configured in his settings. In case the user has a model configured, we won't fallback and show an actionable error message: 1. Model is not set and no fallback available image 2. Model is set, but provider is not authenticated image 3. Model is set, provider is authenticated, but model is not in model list image 4. Model is set, but provider is not recognised image This plays well with the reason why we have the fallback model in the first place: We only want to pick a fallback for users that open Zed for a first time and e.g. have an Anthropic API key present in their environment. As soon as the user manually changes his provider/model we won't apply the fallback anymore. Release Notes: - agent: Improved error messaging when provider is not configured - agent: Improve fallback model selection --------- Co-authored-by: cameron --- crates/agent/src/agent.rs | 5 +- crates/agent/src/thread.rs | 34 ++++- crates/agent_ui/src/agent_ui.rs | 14 +- .../src/conversation_view/thread_view.rs | 127 +++++++++++++++++- crates/language_model/src/registry.rs | 53 ++++++-- crates/language_models/src/language_models.rs | 48 +------ 6 files changed, 210 insertions(+), 71 deletions(-) diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 75e52207f27..381af5e2fa3 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -374,7 +374,10 @@ impl LanguageModels { } } - cx.update(language_models::update_environment_fallback_model); + cx.update(|cx| { + LanguageModelRegistry::global(cx) + .update(cx, |registry, cx| registry.refresh_fallback_model(cx)) + }); }) } } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index fb2b277a634..48b86de73b9 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -35,7 +35,8 @@ use futures::{ }; use futures::{StreamExt, stream}; use gpui::{ - App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity, + App, AppContext, AsyncApp, Context, Entity, EventEmitter, ReadGlobal as _, SharedString, Task, + WeakEntity, }; use heck::ToSnakeCase as _; use language_model::{ @@ -1181,7 +1182,7 @@ enum CompletionError { Other(#[from] anyhow::Error), } -pub(crate) enum ThreadModel { +pub enum ThreadModel { Ready(Arc), Unresolved(SelectedModel), Unset, @@ -1357,7 +1358,11 @@ impl Thread { .and_then(|model| model.speed); let (prompt_capabilities_tx, prompt_capabilities_rx) = watch::channel(Self::prompt_capabilities(model.as_deref())); - let model = model.map_or(ThreadModel::Unset, ThreadModel::Ready); + let model = match model { + Some(model) => ThreadModel::Ready(model), + None => Self::user_configured_model_selection(cx) + .map_or(ThreadModel::Unset, ThreadModel::Unresolved), + }; Self { id: acp::SessionId::new(uuid::Uuid::new_v4().to_string()), prompt_id: PromptId::new(), @@ -1946,6 +1951,10 @@ impl Thread { self.model.as_model() } + pub fn thread_model(&self) -> &ThreadModel { + &self.model + } + pub(crate) fn ensure_model( &mut self, default_model: Option<&Arc>, @@ -1977,6 +1986,7 @@ impl Thread { cx.emit(TokenUsageUpdated(new_usage)); } self.prompt_capabilities_tx.send(new_caps).log_err(); + cx.emit(ModelChanged); for subagent in &self.running_subagents { subagent @@ -2395,6 +2405,20 @@ impl Thread { Self::resolve_model_from_selection(&selection, cx) } + fn user_configured_model_selection(cx: &App) -> Option { + let selection = SettingsStore::global(cx) + .raw_user_settings()? + .content + .agent + .as_ref()? + .default_model + .as_ref()?; + Some(SelectedModel { + provider: LanguageModelProviderId::from(selection.provider.0.clone()), + model: LanguageModelId::from(selection.model.clone()), + }) + } + /// Translate a stored model selection into the configured model from the registry. fn resolve_model_from_selection( selection: &LanguageModelSelection, @@ -4767,6 +4791,10 @@ pub struct TitleUpdated; impl EventEmitter for Thread {} +pub struct ModelChanged; + +impl EventEmitter for Thread {} + /// A channel-based wrapper that delivers tool input to a running tool. /// /// For non-streaming tools, created via `ToolInput::ready()` so `.recv()` resolves immediately. diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index ccaf59f72b0..1640a90297f 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -47,8 +47,8 @@ use editor::{Editor, SelectionEffects, scroll::Autoscroll}; use feature_flags::FeatureFlagAppExt as _; use fs::Fs; use gpui::{ - Action, App, Context, Entity, ImageSource, Resource, SharedString, SharedUri, TaskExt, Window, - actions, + Action, App, Context, Entity, ImageSource, ReadGlobal as _, Resource, SharedString, SharedUri, + TaskExt, Window, actions, }; use language::{ LanguageRegistry, @@ -885,11 +885,12 @@ fn init_language_model_settings(cx: &mut App) { .detach(); cx.subscribe( &LanguageModelRegistry::global(cx), - |_, event: &language_model::Event, cx| match event { + |registry, event: &language_model::Event, cx| match event { language_model::Event::ProviderStateChanged(_) | language_model::Event::AddedProvider(_) | language_model::Event::RemovedProvider(_) | language_model::Event::ProvidersChanged => { + registry.update(cx, |registry, cx| registry.refresh_fallback_model(cx)); update_active_language_model_from_settings(cx); } _ => {} @@ -908,6 +909,12 @@ fn update_active_language_model_from_settings(cx: &mut App) { } } + let should_use_fallback = SettingsStore::global(cx) + .raw_user_settings() + .and_then(|user| user.content.agent.as_ref()) + .and_then(|agent| agent.default_model.as_ref()) + .is_none(); + let default = settings.default_model.as_ref().map(to_selected_model); let inline_assistant = settings .inline_assistant_model @@ -933,6 +940,7 @@ fn update_active_language_model_from_settings(cx: &mut App) { registry.select_commit_message_model(commit_message.as_ref(), cx); registry.select_thread_summary_model(thread_summary.as_ref(), cx); registry.select_inline_alternative_models(inline_alternatives, cx); + registry.set_should_use_fallback(should_use_fallback); }); } diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 2d7c6b6ca40..c2f06da5261 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -924,6 +924,20 @@ impl ThreadView { cx.notify(); }, )); + + // A "no model selected" error is stale as soon as the thread has a + // usable model + if let Some(native_thread) = native_connection.thread(thread.read(cx).session_id(), cx) + { + subscriptions.push(cx.subscribe( + &native_thread, + |this: &mut Self, _thread, _event: &agent::ModelChanged, cx| { + if matches!(this.thread_error, Some(ThreadError::NoModelSelected)) { + this.clear_thread_error(cx); + } + }, + )); + } } subscriptions.push(cx.observe(&message_editor, |this, editor, cx| { @@ -10643,13 +10657,17 @@ impl ThreadView { false, cx, ), - ThreadError::NoModelSelected => self.render_error_callout( - "No Model Selected", - "Select a model from the model picker below to get started.".into(), - false, - false, - cx, - ), + ThreadError::NoModelSelected => self + .render_model_not_available_error(cx) + .unwrap_or_else(|| { + self.render_error_callout( + "No Model Selected", + "Select a model from the model picker below to get started.".into(), + false, + false, + cx, + ) + }), ThreadError::ApiError { provider } => self.render_error_callout( "API Error", format!( @@ -10750,6 +10768,101 @@ impl ThreadView { .dismiss_action(self.dismiss_error_button(cx)) } + fn render_model_not_available_error(&self, cx: &mut Context) -> Option { + let thread = self.as_native_thread(cx)?; + + let has_authenticated_provider = + LanguageModelRegistry::read_global(cx).has_authenticated_provider(cx); + + let (title, description): (SharedString, SharedString) = + match thread.read(cx).thread_model() { + agent::ThreadModel::Ready(_) => return None, + agent::ThreadModel::Unresolved(selected_model) => { + if let Some(provider) = LanguageModelRegistry::global(cx) + .read(cx) + .provider(&&selected_model.provider) + { + if !provider.is_authenticated(cx) { + ( + format!("Failed to authenticate with {} provider", provider.name()) + .into(), + "Open the settings to configure the selected provider".into(), + ) + } else { + ( + format!("Model {} was not found", selected_model.model.0).into(), + "You may need to reconfigure authentication for this provider" + .into(), + ) + } + } else { + ( + format!("Provider {} was not found", selected_model.provider).into(), + "Open the settings to configure providers".into(), + ) + } + } + agent::ThreadModel::Unset => { + if has_authenticated_provider { + ( + "No model selected".into(), + "Choose a different model or configure other providers to get started" + .into(), + ) + } else { + ( + "No model selected".into(), + "Configure a provider to get started".into(), + ) + } + } + }; + + let callout = Callout::new() + .severity(Severity::Error) + .icon(IconName::XCircle) + .title(title) + .description(description) + .actions_slot( + h_flex() + .gap_1() + .child(self.open_llm_providers_settings_button(cx)) + .when(has_authenticated_provider, |this| { + this.child(self.open_model_selector_button(cx)) + }), + ) + .dismiss_action(self.dismiss_error_button(cx)); + + Some(callout) + } + + fn open_llm_providers_settings_button(&self, cx: &mut Context) -> impl IntoElement { + Button::new("configure-llm-provider", "Configure Provider") + .label_size(LabelSize::Small) + .style(ButtonStyle::Filled) + .on_click(cx.listener(|this, _, window, cx| { + this.clear_thread_error(cx); + window.dispatch_action( + Box::new(zed_actions::OpenSettingsAt { + path: "llm_providers".to_string(), + target: None, + }), + cx, + ); + })) + } + + fn open_model_selector_button(&self, cx: &mut Context) -> impl IntoElement { + Button::new("open-model-selector", "Select Model") + .label_size(LabelSize::Small) + .style(ButtonStyle::Filled) + .key_binding(KeyBinding::for_action(&ToggleModelSelector, cx)) + .on_click(cx.listener(|this, _, window, cx| { + this.clear_thread_error(cx); + window.dispatch_action(ToggleModelSelector.boxed_clone(), cx); + })) + } + fn render_prompt_too_large_error(&self, cx: &mut Context) -> Callout { const MESSAGE: &str = "This conversation is too long for the model's context window. \ Start a new thread or remove some attached files to continue."; diff --git a/crates/language_model/src/registry.rs b/crates/language_model/src/registry.rs index ddc30b5d30a..28033e482f3 100644 --- a/crates/language_model/src/registry.rs +++ b/crates/language_model/src/registry.rs @@ -44,6 +44,8 @@ impl std::fmt::Debug for ConfigurationError { #[derive(Default)] pub struct LanguageModelRegistry { + /// True if the user has *NO* default model configured in settings + should_use_fallback: bool, default_model: Option, /// This model is automatically configured by a user's environment after /// authenticating all providers. It's only used when `default_model` is not set. @@ -151,6 +153,10 @@ impl LanguageModelRegistry { self.default_model.as_ref().unwrap().model.clone() } + pub fn set_should_use_fallback(&mut self, value: bool) { + self.should_use_fallback = value; + } + pub fn register_provider( &mut self, provider: Arc, @@ -357,11 +363,30 @@ impl LanguageModelRegistry { self.default_model = model; } - pub fn set_environment_fallback_model( - &mut self, - model: Option, - cx: &mut Context, - ) { + pub fn refresh_fallback_model(&mut self, cx: &mut Context) { + // If the fallback model was already set or we don't want to use it, do nothing + if !self.should_use_fallback || self.available_fallback_model.is_some() { + return; + } + + let fallback_model = self + .providers() + .iter() + .filter(|provider| provider.is_authenticated(cx)) + .find_map(|provider| { + let model = provider + .default_model(cx) + .or_else(|| provider.recommended_models(cx).first().cloned())?; + Some(ConfiguredModel { + provider: provider.clone(), + model, + }) + }); + + self.set_fallback_model(fallback_model, cx); + } + + fn set_fallback_model(&mut self, model: Option, cx: &mut Context) { if self.default_model.is_none() { match (self.available_fallback_model.as_ref(), model.as_ref()) { (Some(old), Some(new)) if old.is_same_as(new) => {} @@ -417,9 +442,13 @@ impl LanguageModelRegistry { return None; } - self.default_model - .clone() - .or_else(|| self.available_fallback_model.clone()) + self.default_model.clone().or_else(|| { + if self.should_use_fallback { + self.available_fallback_model.clone() + } else { + None + } + }) } pub fn default_fast_model(&self, cx: &App) -> Option { @@ -617,7 +646,7 @@ mod tests { } #[gpui::test] - async fn test_configure_environment_fallback_model(cx: &mut gpui::TestAppContext) { + async fn test_configure_fallback_model(cx: &mut gpui::TestAppContext) { let registry = cx.new(|_| LanguageModelRegistry::default()); let provider = Arc::new(FakeLanguageModelProvider::default()); @@ -631,7 +660,7 @@ mod tests { let provider = registry.provider(&provider.id()).unwrap(); let model = provider.default_model(cx).unwrap(); - registry.set_environment_fallback_model( + registry.set_fallback_model( Some(ConfiguredModel { provider: provider.clone(), model: model.clone(), @@ -639,6 +668,10 @@ mod tests { cx, ); + assert!(registry.default_model().is_none()); + + registry.set_should_use_fallback(true); + let default_model = registry.default_model().unwrap(); assert_eq!(default_model.model.id(), model.id()); assert_eq!(default_model.provider.id(), provider.id()); diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index a9ec2b028ff..ce968e3d611 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -5,9 +5,7 @@ use client::{Client, UserStore}; use collections::{HashMap, HashSet}; use credentials_provider::CredentialsProvider; use gpui::{App, Context, Entity}; -use language_model::{ - ConfiguredModel, LanguageModelProviderId, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID, -}; +use language_model::{LanguageModelProviderId, LanguageModelRegistry}; use provider::deepseek::DeepSeekLanguageModelProvider; pub mod extension; @@ -140,50 +138,6 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { .detach(); } -/// Recomputes and sets the [`LanguageModelRegistry`]'s environment fallback -/// model based on currently authenticated providers. -/// -/// Prefers the Zed cloud provider so that, once the user is signed in, we -/// always pick a Zed-hosted model over models from other authenticated -/// providers in the environment. If the Zed cloud provider is authenticated -/// but hasn't finished loading its models yet, we don't fall back to another -/// provider to avoid flickering between providers during sign in. -pub fn update_environment_fallback_model(cx: &mut App) { - let registry = LanguageModelRegistry::global(cx); - let fallback_model = { - let registry = registry.read(cx); - let cloud_provider = registry.provider(&ZED_CLOUD_PROVIDER_ID); - if cloud_provider - .as_ref() - .is_some_and(|provider| provider.is_authenticated(cx)) - { - cloud_provider.and_then(|provider| { - let model = provider - .default_model(cx) - .or_else(|| provider.recommended_models(cx).first().cloned())?; - Some(ConfiguredModel { provider, model }) - }) - } else { - registry - .providers() - .iter() - .filter(|provider| provider.is_authenticated(cx)) - .find_map(|provider| { - let model = provider - .default_model(cx) - .or_else(|| provider.recommended_models(cx).first().cloned())?; - Some(ConfiguredModel { - provider: provider.clone(), - model, - }) - }) - } - }; - registry.update(cx, |registry, cx| { - registry.set_environment_fallback_model(fallback_model, cx); - }); -} - #[derive(Default, PartialEq, Eq)] struct CompatibleProviders(HashMap, CompatibleProviderKind>); From c4a9b1aa4bb64497b4eef84fb7f2c5988bd6c53b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Soares?= <37777652+Dnreikronos@users.noreply.github.com> Date: Mon, 6 Jul 2026 05:51:35 -0300 Subject: [PATCH 079/197] Fix blame hover popover not showing on first trigger when inline blame is disabled (#50769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ● Closes #50285 ## Summary When `inline_blame` is disabled in settings and `editor::BlameHover` is triggered, the blame popover fails to appear on the first invocation because `start_git_blame` kicks off an async task to fetch blame data, but `blame_hover` immediately tries to read that data synchronously before it's available. This fix registers a one-shot observation on the blame entity when it's freshly created, so the popover is shown once blame data has finished generating. Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [ ] ~Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)~ (No UI changes) ## Videos: ### Before: https://github.com/user-attachments/assets/d2ca4f8e-8186-49af-9908-5a82bfca0de2 Ps: pressed the keybind two times ### After: https://github.com/user-attachments/assets/08575cd9-2bbc-462d-92fa-f0e7ef23d8d6 ## Release Notes: - Fixed blame hover popover not appearing on first trigger when inline blame is disabled (#50285) --------- Co-authored-by: Chris Biscardi --- crates/editor/src/editor.rs | 2 + crates/editor/src/git.rs | 26 +++++++-- crates/editor/src/git/blame.rs | 97 ++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 3 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 913ab20c4c1..d2eb44ecc87 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1079,6 +1079,7 @@ pub struct Editor { show_selection_menu: Option, blame: Option>, blame_subscription: Option, + pending_blame_hover_observation: Option, custom_context_menu: Option< Box< dyn 'static @@ -2399,6 +2400,7 @@ impl Editor { }), blame: None, blame_subscription: None, + pending_blame_hover_observation: None, bookmark_store, breakpoint_store, diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs index 7224fa15d4c..6bb94a892dc 100644 --- a/crates/editor/src/git.rs +++ b/crates/editor/src/git.rs @@ -638,6 +638,29 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { + let just_started = self.blame.is_none(); + if just_started { + self.start_git_blame(true, window, cx); + } + let Some(blame) = self.blame.as_ref() else { + return; + }; + + if just_started && !blame.read(cx).has_generated_entries() { + let subscription = cx.observe_in(blame, window, |editor, blame, window, cx| { + if blame.read(cx).has_generated_entries() { + editor.pending_blame_hover_observation.take(); + editor.show_blame_hover_popover(window, cx); + } + }); + self.pending_blame_hover_observation = Some(subscription); + return; + } + + self.show_blame_hover_popover(window, cx); + } + + fn show_blame_hover_popover(&mut self, window: &mut Window, cx: &mut Context) { let snapshot = self.snapshot(window, cx); let cursor = self .selections @@ -647,9 +670,6 @@ impl Editor { return; }; - if self.blame.is_none() { - self.start_git_blame(true, window, cx); - } let Some(blame) = self.blame.as_ref() else { return; }; diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs index 0e6ad1cb0ea..e96a1087bd5 100644 --- a/crates/editor/src/git/blame.rs +++ b/crates/editor/src/git/blame.rs @@ -1295,4 +1295,101 @@ mod tests { filename: String::new(), } } + + #[gpui::test] + async fn test_blame_hover_shows_popover_on_first_trigger(cx: &mut gpui::TestAppContext) { + init_test(cx); + + cx.update(|cx| { + use gpui::UpdateGlobal; + settings::SettingsStore::update_global( + cx, + |store: &mut settings::SettingsStore, cx| { + store + .set_user_settings(r#"{"git": {"inline_blame": {"enabled": false}}}"#, cx) + .expect("failed to set user settings"); + }, + ); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/my-repo", + json!({ + ".git": {}, + "file.txt": "line 1\nline 2\nline 3\n" + }), + ) + .await; + + fs.set_blame_for_repo( + Path::new("/my-repo/.git"), + vec![( + repo_path("file.txt"), + Blame { + entries: vec![ + blame_entry("1b1b1b", 0..1), + blame_entry("2c2c2c", 1..2), + blame_entry("3d3d3d", 2..3), + ], + ..Default::default() + }, + )], + ); + + let project = project::Project::test(fs, ["/my-repo".as_ref()], cx).await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/my-repo/file.txt", cx) + }) + .await + .unwrap(); + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + + let (editor, cx) = cx.add_window_view(|window, cx| { + crate::test::build_editor_with_project(project, multi_buffer, window, cx) + }); + + // Verify blame is not loaded yet + editor.update(cx, |editor, _cx| { + assert!( + editor.blame().is_none(), + "blame should not be loaded initially" + ); + }); + + // Focus the editor so that blame generation proceeds + editor.update_in(cx, |editor, window, cx| { + editor.focus_handle.focus(window, cx); + }); + + // Trigger BlameHover — this should start blame loading and defer showing the popover + editor.update_in(cx, |editor, window, cx| { + assert!(editor.blame().is_none()); + editor.blame_hover(&crate::BlameHover, window, cx); + assert!( + editor.blame().is_some(), + "blame entity should be created after blame_hover" + ); + assert!( + editor.pending_blame_hover_observation.is_some(), + "should have registered an observation to wait for blame data" + ); + }); + + // Let the async blame generation complete + cx.run_until_parked(); + + // The observation should have fired and cleaned itself up + editor.update(cx, |editor, cx| { + assert!( + editor.pending_blame_hover_observation.is_none(), + "observation should be consumed after blame data is generated" + ); + assert!( + editor.blame().unwrap().read(cx).has_generated_entries(), + "blame should have generated entries" + ); + }); + } } From 159246f0083787124160620118ac35df5f0f3754 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 6 Jul 2026 11:14:38 +0200 Subject: [PATCH 080/197] acp: Support boolean ACP config options (#60446) Removing the feature flag now that the API is stable. Release Notes: - acp: Support boolean toggles for ACP session configuration in agents that use them. --------- Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- crates/agent_servers/src/acp.rs | 115 ++++---------------------- crates/agent_ui/src/config_options.rs | 109 +++--------------------- 2 files changed, 24 insertions(+), 200 deletions(-) diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 9acc88da755..884b0efd779 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -772,21 +772,20 @@ fn client_capabilities_for_agent( .write_text_file(true)) .terminal(true) .auth(acp::AuthCapabilities::new().terminal(true)) + .session( + acp::ClientSessionCapabilities::new().config_options( + acp::SessionConfigOptionsCapabilities::new() + .boolean(acp::BooleanConfigOptionCapabilities::new()), + ), + ) .meta(meta); if supports_beta_features { - capabilities = capabilities - .elicitation( - acp::ElicitationCapabilities::new() - .form(acp::ElicitationFormCapabilities::new()) - .url(acp::ElicitationUrlCapabilities::new()), - ) - .session( - acp::ClientSessionCapabilities::new().config_options( - acp::SessionConfigOptionsCapabilities::new() - .boolean(acp::BooleanConfigOptionCapabilities::new()), - ), - ); + capabilities = capabilities.elicitation( + acp::ElicitationCapabilities::new() + .form(acp::ElicitationFormCapabilities::new()) + .url(acp::ElicitationUrlCapabilities::new()), + ); } capabilities @@ -1300,7 +1299,6 @@ impl AcpConnection { cx: &mut AsyncApp, ) { let id = self.id.clone(); - let apply_boolean_defaults = cx.update(|cx| cx.has_flag::()); let defaults_to_apply: Vec<_> = { let config_opts_ref = config_options.borrow(); config_opts_ref @@ -1333,9 +1331,6 @@ impl AcpConnection { _ => None, } } - acp::SessionConfigKind::Boolean(_) if !apply_boolean_defaults => { - return None; - } acp::SessionConfigKind::Boolean(_) => default_value .as_bool() .map(acp::SessionConfigOptionValue::boolean), @@ -3045,8 +3040,8 @@ mod tests { } #[test] - fn client_capabilities_include_boolean_config_options_when_supported() { - let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), true); + fn client_capabilities_include_boolean_config_options() { + let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false); assert!( capabilities @@ -3057,13 +3052,6 @@ mod tests { ); } - #[test] - fn client_capabilities_omit_boolean_config_options_when_unsupported() { - let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false); - - assert!(capabilities.session.is_none()); - } - #[test] fn terminal_auth_task_builds_spawn_from_prebuilt_command() { let command = AgentServerCommand { @@ -3549,69 +3537,7 @@ mod tests { } #[gpui::test] - async fn default_config_options_skip_boolean_defaults_when_acp_beta_is_disabled( - cx: &mut gpui::TestAppContext, - ) { - cx.update(|cx| init_settings_with_acp_beta_override(false, cx)); - - let (connection, set_config_requests) = connect_config_defaults_test_agent(cx).await; - connection.defaults.set( - None, - HashMap::from_iter([ - ( - "web_search".to_string(), - AgentConfigOptionValue::Boolean(true), - ), - ("mode".to_string(), AgentConfigOptionValue::from("manual")), - ]), - ); - let config_options = Rc::new(RefCell::new(vec![ - acp::SessionConfigOption::boolean("web_search", "Web Search", false), - acp::SessionConfigOption::select( - "mode", - "Mode", - "auto", - vec![ - acp::SessionConfigSelectOption::new("auto", "Auto"), - acp::SessionConfigSelectOption::new("manual", "Manual"), - ], - ), - ])); - - let mut async_cx = cx.to_async(); - connection.apply_default_config_options( - &acp::SessionId::new("session-config-defaults"), - &config_options, - &mut async_cx, - ); - drop(async_cx); - cx.run_until_parked(); - - let requests = set_config_requests - .lock() - .expect("set config requests mutex poisoned"); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].config_id, acp::SessionConfigId::new("mode")); - assert_eq!( - requests[0].value, - acp::SessionConfigOptionValue::value_id("manual") - ); - - let options = config_options.borrow(); - assert!( - matches!(&options[0].kind, acp::SessionConfigKind::Boolean(boolean) if !boolean.current_value) - ); - assert!( - matches!(&options[1].kind, acp::SessionConfigKind::Select(select) if select.current_value == acp::SessionConfigValueId::new("manual")) - ); - } - - #[gpui::test] - async fn default_config_options_apply_boolean_defaults_when_acp_beta_is_enabled( - cx: &mut gpui::TestAppContext, - ) { - cx.update(|cx| init_settings_with_acp_beta_override(true, cx)); - + async fn default_config_options_apply_boolean_defaults(cx: &mut gpui::TestAppContext) { let (connection, set_config_requests) = connect_config_defaults_test_agent(cx).await; connection.defaults.set( None, @@ -3654,19 +3580,6 @@ mod tests { ); } - fn init_settings_with_acp_beta_override(enabled: bool, cx: &mut App) { - let mut store = settings::SettingsStore::test(cx); - store.register_setting::(); - store.update_user_settings(cx, |content| { - content.feature_flags.get_or_insert_default().insert( - AcpBetaFeatureFlag::NAME.to_string(), - if enabled { "on" } else { "off" }.to_string(), - ); - }); - cx.set_global(store); - cx.update_flags(false, Vec::new()); - } - async fn connect_config_defaults_test_agent( cx: &mut gpui::TestAppContext, ) -> ( diff --git a/crates/agent_ui/src/config_options.rs b/crates/agent_ui/src/config_options.rs index 4dcd72cbdf9..df4eecaccd5 100644 --- a/crates/agent_ui/src/config_options.rs +++ b/crates/agent_ui/src/config_options.rs @@ -5,7 +5,6 @@ use agent_client_protocol::schema::v1 as acp; use agent_servers::AgentServer; use collections::HashSet; -use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _}; use fs::Fs; use fuzzy::StringMatchCandidate; use gpui::{ @@ -99,9 +98,8 @@ impl ConfigOptionsView { favorites_only: bool, cx: &mut Context, ) -> bool { - let render_boolean_config_options = should_render_boolean_config_options(cx); let Some(config_id) = self.first_config_option_id_matching(category, |option| { - Self::can_cycle_config_option(option, favorites_only, render_boolean_config_options) + Self::can_cycle_config_option(option, favorites_only) }) else { return false; }; @@ -144,14 +142,10 @@ impl ConfigOptionsView { .map(|option| option.id) } - fn can_cycle_config_option( - option: &acp::SessionConfigOption, - favorites_only: bool, - render_boolean_config_options: bool, - ) -> bool { + fn can_cycle_config_option(option: &acp::SessionConfigOption, favorites_only: bool) -> bool { match &option.kind { acp::SessionConfigKind::Select(_) => true, - acp::SessionConfigKind::Boolean(_) => !favorites_only && render_boolean_config_options, + acp::SessionConfigKind::Boolean(_) => !favorites_only, _ => false, } } @@ -213,7 +207,7 @@ impl ConfigOptionsView { )) } acp::SessionConfigKind::Boolean(boolean) => { - if favorites_only || !should_render_boolean_config_options(cx) { + if favorites_only { None } else { Some(acp::SessionConfigOptionValue::boolean( @@ -545,10 +539,6 @@ impl Render for ConfigOptionSelector { .into_any_element() } acp::SessionConfigKind::Boolean(boolean) => { - if !should_render_boolean_config_options(cx) { - return div().into_any_element(); - } - let option_id = option.id.clone(); let option_name: SharedString = option.name.clone().into(); let option_description: Option = @@ -991,10 +981,6 @@ fn setting_value_for_config_option_value( } } -fn should_render_boolean_config_options(cx: &App) -> bool { - cx.has_flag::() -} - fn options_to_picker_entries( options: &[ConfigOptionValue], favorites: &HashSet, @@ -1110,9 +1096,8 @@ fn count_config_options(option: &acp::SessionConfigOption) -> usize { mod tests { use super::*; use acp_thread::AgentConnection; - use feature_flags::FeatureFlag as _; use fs::FakeFs; - use gpui::{TestAppContext, UpdateGlobal}; + use gpui::TestAppContext; use parking_lot::Mutex; use project::{AgentId, Project}; use std::{any::Any, cell::RefCell}; @@ -1178,9 +1163,6 @@ mod tests { let fs: Arc = FakeFs::new(cx.executor()); cx.update(|cx| { - init_feature_flag_settings(cx); - set_feature_flag_override(AcpBetaFeatureFlag::NAME, "on", cx); - let config_options: Rc = config_options.clone(); let agent_server: Rc = agent_server.clone(); let fs = fs.clone(); @@ -1215,41 +1197,7 @@ mod tests { } #[gpui::test] - fn cycling_hidden_boolean_config_option_is_unhandled(cx: &mut TestAppContext) { - let agent_server = Rc::new(TestAgentServer::default()); - let config_options = Rc::new(TestSessionConfigOptions::new(vec![ - acp::SessionConfigOption::boolean("web_search", "Web Search", false) - .category(acp::SessionConfigOptionCategory::ModelConfig), - ])); - let fs: Arc = FakeFs::new(cx.executor()); - - cx.update(|cx| { - init_feature_flag_settings(cx); - set_feature_flag_override(AcpBetaFeatureFlag::NAME, "off", cx); - - let config_options: Rc = config_options.clone(); - let agent_server: Rc = agent_server.clone(); - let fs = fs.clone(); - let view = cx.new(|_| ConfigOptionsView { - config_option_ids: ConfigOptionsView::config_option_ids(&config_options), - config_options, - selectors: Vec::new(), - agent_server, - fs, - _refresh_task: Task::ready(()), - }); - - assert!(!view.update(cx, |view, cx| { - view.cycle_category_option(acp::SessionConfigOptionCategory::ModelConfig, false, cx) - })); - }); - - assert!(agent_server.saved_defaults.lock().is_empty()); - assert!(config_options.set_values.borrow().is_empty()); - } - - #[gpui::test] - fn cycling_category_skips_hidden_boolean_config_option(cx: &mut TestAppContext) { + fn cycling_category_cycles_boolean_config_option_first(cx: &mut TestAppContext) { let agent_server = Rc::new(TestAgentServer::default()); let config_options = Rc::new(TestSessionConfigOptions::new(vec![ acp::SessionConfigOption::boolean("web_search", "Web Search", false) @@ -1268,9 +1216,6 @@ mod tests { let fs: Arc = FakeFs::new(cx.executor()); cx.update(|cx| { - init_feature_flag_settings(cx); - set_feature_flag_override(AcpBetaFeatureFlag::NAME, "off", cx); - let config_options: Rc = config_options.clone(); let agent_server: Rc = agent_server.clone(); let fs = fs.clone(); @@ -1291,15 +1236,15 @@ mod tests { assert_eq!( agent_server.saved_defaults.lock().as_slice(), &[( - "model".to_string(), - Some(AgentConfigOptionValue::ValueId("large".to_string())) + "web_search".to_string(), + Some(AgentConfigOptionValue::Boolean(true)) )] ); assert_eq!( config_options.set_values.borrow().as_slice(), &[( - "model".to_string(), - acp::SessionConfigOptionValue::value_id("large") + "web_search".to_string(), + acp::SessionConfigOptionValue::boolean(true) )] ); } @@ -1330,45 +1275,11 @@ mod tests { assert!(!handled); } - #[gpui::test] - fn boolean_config_option_rendering_is_beta_gated(cx: &mut TestAppContext) { - cx.update(|cx| { - init_feature_flag_settings(cx); - - cx.update_flags(false, Vec::new()); - set_feature_flag_override(AcpBetaFeatureFlag::NAME, "off", cx); - assert!(!should_render_boolean_config_options(cx)); - - set_feature_flag_override(AcpBetaFeatureFlag::NAME, "on", cx); - assert!(should_render_boolean_config_options(cx)); - }); - } - #[derive(Default)] struct TestAgentServer { saved_defaults: Arc)>>>, } - fn init_feature_flag_settings(cx: &mut App) { - let store = SettingsStore::test(cx); - cx.set_global(store); - SettingsStore::update_global(cx, |store, _| { - store.register_setting::(); - }); - cx.update_flags(false, Vec::new()); - } - - fn set_feature_flag_override(name: &str, value: &str, cx: &mut App) { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |content| { - content - .feature_flags - .get_or_insert_default() - .insert(name.to_string(), value.to_string()); - }); - }); - } - impl AgentServer for TestAgentServer { fn logo(&self) -> IconName { IconName::ZedAssistant From c545fb67d0ce13e335bff76f7c08986000333f2c Mon Sep 17 00:00:00 2001 From: Kaedin Date: Mon, 6 Jul 2026 03:30:01 -0700 Subject: [PATCH 081/197] docs: Add Tailwind CSS LSP configuration for Gleam (#58115) Documents how to configure the Tailwind CSS language server for Gleam, and updates the link on the Tailwind landing page to anchor at the new section so it matches the pattern used for Astro, ERB, HEEx, HTML, TypeScript, JavaScript, PHP, Svelte, and Vue. The config follows what was documented in #43968 when Gleam was added to `tailwind.rs`. Refs: #43969 Release Notes: - N/A --------- Co-authored-by: Kaedin Hano-Hollis <180361443+kaedinhanohano@users.noreply.github.com> Co-authored-by: Kunall Banerjee --- docs/src/languages/gleam.md | 25 +++++++++++++++++++++++++ docs/src/languages/tailwindcss.md | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/src/languages/gleam.md b/docs/src/languages/gleam.md index dba403d4ede..af19b77886a 100644 --- a/docs/src/languages/gleam.md +++ b/docs/src/languages/gleam.md @@ -10,6 +10,31 @@ Gleam support is available through the [Gleam extension](https://github.com/glea - Tree-sitter: [gleam-lang/tree-sitter-gleam](https://github.com/gleam-lang/tree-sitter-gleam) - Language Server: [gleam lsp](https://github.com/gleam-lang/gleam/tree/main/compiler-core/src/language_server) +## Using the Tailwind CSS Language Server with Gleam + +To get all the features (autocomplete, linting, etc.) from the [Tailwind CSS language server](https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme) in Gleam files, you need to enable the language server for Gleam and configure where it should look for CSS classes by adding the following to your `settings.json`: + +```json [settings] +{ + "languages": { + "Gleam": { + "language_servers": ["tailwindcss-language-server", "..."] + } + }, + "lsp": { + "tailwindcss-language-server": { + "settings": { + "experimental": { + "classRegex": ["\"([^\"]*)\""] + } + } + } + } +} +``` + +This works with plain string literals and with [Lustre](https://github.com/lustre-labs/lustre) view templates where class names are passed as string arguments. + See also: - [Elixir](./elixir.md) diff --git a/docs/src/languages/tailwindcss.md b/docs/src/languages/tailwindcss.md index e461aa2d7fa..4644549ea0b 100644 --- a/docs/src/languages/tailwindcss.md +++ b/docs/src/languages/tailwindcss.md @@ -14,7 +14,7 @@ Languages which can be used with Tailwind CSS in Zed: - [Astro](./astro.md#using-the-tailwind-css-language-server-with-astro) - [CSS](./css.md) - [ERB](./ruby.md#using-the-tailwind-css-language-server-with-ruby) -- [Gleam](./gleam.md) +- [Gleam](./gleam.md#using-the-tailwind-css-language-server-with-gleam) - [HEEx](./elixir.md#using-the-tailwind-css-language-server-with-heex-templates) - [HTML](./html.md#using-the-tailwind-css-language-server-with-html) - [TypeScript](./typescript.md#using-the-tailwind-css-language-server-with-typescript) From 0d789ded0ebdeaab69da212f7e0bf1c49d41131d Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Mon, 6 Jul 2026 13:21:14 +0200 Subject: [PATCH 082/197] extension_rollout: Allow rollout for `extension-workflows` tag (#60354) Also cleans up the tag update step to instead use the GitHub API for this. Release Notes: - N/A --- .../workflows/extension_workflow_rollout.yml | 29 +++++++++---------- .../workflows/extension_workflow_rollout.rs | 28 +++++++----------- 2 files changed, 23 insertions(+), 34 deletions(-) diff --git a/.github/workflows/extension_workflow_rollout.yml b/.github/workflows/extension_workflow_rollout.yml index c1e61822df6..62a9c75d518 100644 --- a/.github/workflows/extension_workflow_rollout.yml +++ b/.github/workflows/extension_workflow_rollout.yml @@ -16,7 +16,7 @@ on: default: '' jobs: fetch_extension_repos: - if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && github.ref == 'refs/heads/main' + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && (github.ref == 'refs/tags/extension-workflows' || github.ref == 'refs/heads/main') runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: checkout_zed_repo @@ -220,21 +220,18 @@ jobs: clean: false fetch-depth: 0 token: ${{ steps.generate-token.outputs.token }} - - name: extension_workflow_rollout::create_rollout_tag::update_rollout_tag - run: | - if git rev-parse "extension-workflows" >/dev/null 2>&1; then - git tag -d "extension-workflows" - git push origin ":refs/tags/extension-workflows" || true - fi - - echo "Creating new tag 'extension-workflows' at $(git rev-parse --short HEAD)" - git tag "extension-workflows" - git push origin "extension-workflows" - env: - GIT_AUTHOR_NAME: zed-zippy[bot] - GIT_AUTHOR_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com - GIT_COMMITTER_NAME: zed-zippy[bot] - GIT_COMMITTER_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com + - name: steps::update_tag + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b + with: + script: | + github.rest.git.updateRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: 'tags/extension-workflows', + sha: context.sha, + force: true + }) + github-token: ${{ steps.generate-token.outputs.token }} timeout-minutes: 1 defaults: run: diff --git a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs index e97b6edff47..9eb9d456a62 100644 --- a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs +++ b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs @@ -5,6 +5,8 @@ use indoc::formatdoc; use indoc::indoc; use serde_json::json; +use crate::tasks::workflows::steps::GitRef; +use crate::tasks::workflows::steps::RefSha; use crate::tasks::workflows::steps::{ CheckoutStep, DownloadArtifactStep, IfNoFilesFound, ResultEncoding, TokenPermissions, UploadArtifactStep, cache_rust_dependencies_namespace, @@ -13,8 +15,7 @@ use crate::tasks::workflows::vars::JobOutput; use crate::tasks::workflows::{ runners, steps::{ - self, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob, RepositoryTarget, ZippyGitIdentity, - generate_token, named, + self, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob, RepositoryTarget, generate_token, named, }, vars::{self, StepOutput, WorkflowInput}, }; @@ -155,8 +156,7 @@ fn fetch_extension_repos(filter_repos_input: &WorkflowInput) -> (NamedJob, JobOu let job = Job::default() .cond(Expression::new(format!( - "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.ref == 'refs/heads/main'" - ))) + "{DEFAULT_REPOSITORY_OWNER_GUARD} && (github.ref == 'refs/tags/{ROLLOUT_TAG_NAME}' || github.ref == 'refs/heads/main')"))) .runs_on(runners::LINUX_SMALL) .timeout_minutes(10u32) .outputs([ @@ -336,19 +336,6 @@ fn create_rollout_tag(rollout_job: &NamedJob, filter_repos_input: &WorkflowInput steps::checkout_repo().with_full_history().with_token(token) } - fn update_rollout_tag() -> Step { - named::bash(formatdoc! {r#" - if git rev-parse "{ROLLOUT_TAG_NAME}" >/dev/null 2>&1; then - git tag -d "{ROLLOUT_TAG_NAME}" - git push origin ":refs/tags/{ROLLOUT_TAG_NAME}" || true - fi - - echo "Creating new tag '{ROLLOUT_TAG_NAME}' at $(git rev-parse --short HEAD)" - git tag "{ROLLOUT_TAG_NAME}" - git push origin "{ROLLOUT_TAG_NAME}" - "#}) - } - let (authenticate, token) = generate_token(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY) .for_repository(RepositoryTarget::current()) @@ -365,7 +352,12 @@ fn create_rollout_tag(rollout_job: &NamedJob, filter_repos_input: &WorkflowInput .timeout_minutes(1u32) .add_step(authenticate) .add_step(checkout_zed_repo(&token)) - .add_step(update_rollout_tag().with_zippy_git_identity()); + .add_step(steps::update_ref( + GitRef::Tag(ROLLOUT_TAG_NAME.to_owned()), + RefSha::Context, + &token, + true, + )); named::job(job) } From 54bf918329ba3bf4ccebee8ea98e0acc0291e201 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 6 Jul 2026 13:37:43 +0200 Subject: [PATCH 083/197] acp: Set agent as default when installing it from registry (#60452) This PR makes it so that when you install an agent from the registry we set it as the default agent. We apply the same behaviour when installing an ACP agent from the onboarding page, which should make it less confusing for new users (they won't see the Zed agent when they see a new thread, but instead see the agent they installed) https://github.com/user-attachments/assets/81f4711f-4c9a-4606-882a-9f944c90dfa3 Release Notes: - agent: Improved onboarding experience when installing ACP agents --- crates/agent_ui/src/agent_panel.rs | 128 ++++++++++++++++++----- crates/agent_ui/src/agent_registry_ui.rs | 32 +++--- crates/onboarding/src/basics_page.rs | 32 +++--- crates/zed_actions/src/lib.rs | 11 ++ 4 files changed, 153 insertions(+), 50 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 40c15c71794..5575046e25d 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -26,7 +26,7 @@ use zed_actions::{ agent::{ AddSelectionToThread, ConflictContent, LogoutAgent, OpenSettings, ReauthenticateAgent, ResetAgentZoom, ResetOnboarding, ResolveConflictedFilesWithAgent, - ResolveConflictsWithAgent, ReviewBranchDiff, + ResolveConflictsWithAgent, ReviewBranchDiff, SelectAgent, }, assistant::{ FocusAgent, ManageSkills, OpenGlobalAgentsMdRules, OpenProjectAgentsMdRules, Toggle, @@ -423,6 +423,14 @@ pub fn init(cx: &mut App) { }); } }) + .register_action(|workspace, action: &SelectAgent, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + let agent = AgentId::new(action.agent.clone()).into(); + panel.select_agent(agent, window, cx); + }); + } + }) .register_action(|workspace, action: &ManageSkills, window, cx| { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); @@ -1913,6 +1921,43 @@ impl AgentPanel { self.activate_new_thread(true, AgentThreadSource::AgentPanel, window, cx); } + fn set_selected_agent_and_persist(&mut self, agent: Agent, cx: &mut Context) { + if self.selected_agent != agent { + self.selected_agent = agent.clone(); + self.serialize(cx); + } + + cx.background_spawn({ + let kvp = KeyValueStore::global(cx); + async move { + write_global_last_used_agent(kvp, agent).await; + } + }) + .detach(); + } + + /// Sets the panel's selected agent without opening the panel or focusing + /// it, so the agent is launched the next time the panel is opened (or + /// right away, if the panel is already showing the empty new-thread + /// draft). + pub fn select_agent(&mut self, agent: Agent, window: &mut Window, cx: &mut Context) { + if self.project.read(cx).is_via_collab() && !agent.is_native() { + return; + } + + let showing_new_draft = matches!( + (&self.base_view, &self.draft_thread), + (BaseView::AgentThread { conversation_view }, Some(draft)) + if conversation_view.entity_id() == draft.entity_id() + ); + + if matches!(self.base_view, BaseView::AgentThread { .. }) && showing_new_draft { + self.set_selected_agent_and_persist(agent, cx); + self.activate_draft(false, AgentThreadSource::AgentPanel, window, cx); + cx.notify(); + } + } + pub fn new_terminal( &mut self, workspace: Option<&Workspace>, @@ -3180,18 +3225,7 @@ impl AgentPanel { cx, ); if let Some(original) = saved_selected_agent { - if self.selected_agent != original { - self.selected_agent = original.clone(); - self.serialize(cx); - // Restore the last-used-agent in persistent storage as well. - cx.background_spawn({ - let kvp = KeyValueStore::global(cx); - async move { - write_global_last_used_agent(kvp, original).await; - } - }) - .detach(); - } + self.set_selected_agent_and_persist(original, cx); } let thread_id = thread.conversation_view.read(cx).thread_id; self.retained_threads @@ -4501,19 +4535,7 @@ impl AgentPanel { let workspace = self.workspace.clone(); let project = self.project.clone(); - if self.selected_agent != agent { - self.selected_agent = agent.clone(); - self.serialize(cx); - } - - cx.background_spawn({ - let kvp = KeyValueStore::global(cx); - let agent = agent.clone(); - async move { - write_global_last_used_agent(kvp, agent).await; - } - }) - .detach(); + self.set_selected_agent_and_persist(agent.clone(), cx); let server = server_override .unwrap_or_else(|| agent.server(self.fs.clone(), self.thread_store.clone())); @@ -11318,6 +11340,60 @@ mod tests { }); } + #[gpui::test] + async fn test_select_agent_action_updates_visible_draft(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + cx.update(|cx| { + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + ::set_global(fs.clone(), cx); + }); + + fs.insert_tree("/project", json!({ "file.txt": "" })).await; + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + panel.update_in(cx, |panel, window, cx| { + panel.activate_draft(false, AgentThreadSource::AgentPanel, window, cx); + }); + + cx.dispatch_action(SelectAgent { + agent: "my-configured-agent".to_string(), + }); + cx.run_until_parked(); + + let expected_agent = Agent::Custom { + id: "my-configured-agent".into(), + }; + + panel.read_with(cx, |panel, cx| { + let draft = panel.draft_thread.as_ref().expect("draft should exist"); + assert_eq!(panel.selected_agent, expected_agent); + assert_eq!(*draft.read(cx).agent_key(), expected_agent); + }); + + let kvp = cx.update(|_, cx| KeyValueStore::global(cx)); + assert_eq!( + read_global_last_used_agent(&kvp), + Some(expected_agent), + "the selection should be persisted as the global last-used agent" + ); + } + #[gpui::test] async fn test_workspaces_maintain_independent_agent_selection(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent_ui/src/agent_registry_ui.rs b/crates/agent_ui/src/agent_registry_ui.rs index 897a8536624..a4fb6bfc60e 100644 --- a/crates/agent_ui/src/agent_registry_ui.rs +++ b/crates/agent_ui/src/agent_registry_ui.rs @@ -514,19 +514,27 @@ impl AgentRegistryPage { .size(IconSize::Small) .color(Color::Muted), ) - .on_click(move |_, _, cx| { - let agent_id = agent_id.clone(); - update_settings_file(fs.clone(), cx, move |settings, _| { - let agent_servers = settings.agent_servers.get_or_insert_default(); - agent_servers.entry(agent_id).or_insert_with(|| { - settings::CustomAgentServerSettings::Registry { - default_mode: None, - env: Default::default(), - default_config_options: HashMap::default(), - favorite_config_option_values: HashMap::default(), - } - }); + .on_click(move |_, window, cx| { + update_settings_file(fs.clone(), cx, { + let agent_id = agent_id.clone(); + move |settings, _| { + let agent_servers = settings.agent_servers.get_or_insert_default(); + agent_servers.entry(agent_id).or_insert_with(|| { + settings::CustomAgentServerSettings::Registry { + default_mode: None, + env: Default::default(), + default_config_options: HashMap::default(), + favorite_config_option_values: HashMap::default(), + } + }); + } }); + window.dispatch_action( + Box::new(zed_actions::agent::SelectAgent { + agent: agent_id.clone(), + }), + cx, + ); }) } RegistryInstallStatus::InstalledRegistry => { diff --git a/crates/onboarding/src/basics_page.rs b/crates/onboarding/src/basics_page.rs index a4b17a0c09b..40fe15c0894 100644 --- a/crates/onboarding/src/basics_page.rs +++ b/crates/onboarding/src/basics_page.rs @@ -565,20 +565,28 @@ fn render_registry_agent_button( .name(agent.name().clone()) .state(state_element) .disabled(installed) - .on_click(move |_, _, cx| { + .on_click(move |_, window, cx| { telemetry::event!("Welcome Agent Install Clicked", agent = agent_id.as_str()); - let agent_id = agent_id.clone(); - update_settings_file(fs.clone(), cx, move |settings, _| { - let agent_servers = settings.agent_servers.get_or_insert_default(); - agent_servers.entry(agent_id).or_insert_with(|| { - CustomAgentServerSettings::Registry { - env: Default::default(), - default_mode: None, - default_config_options: HashMap::default(), - favorite_config_option_values: HashMap::default(), - } - }); + update_settings_file(fs.clone(), cx, { + let agent_id = agent_id.clone(); + move |settings, _| { + let agent_servers = settings.agent_servers.get_or_insert_default(); + agent_servers.entry(agent_id).or_insert_with(|| { + CustomAgentServerSettings::Registry { + env: Default::default(), + default_mode: None, + default_config_options: HashMap::default(), + favorite_config_option_values: HashMap::default(), + } + }); + } }); + window.dispatch_action( + Box::new(zed_actions::agent::SelectAgent { + agent: agent_id.clone(), + }), + cx, + ); }) } diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index a11eca78e0b..7eb78ca0ecc 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -567,6 +567,17 @@ pub mod agent { ] ); + /// Selects the agent used for new threads in the agent panel, without + /// opening the panel. The selected agent is launched the next time the + /// panel is opened. + #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] + #[action(namespace = agent)] + #[serde(deny_unknown_fields)] + pub struct SelectAgent { + /// The id of the agent to select. + pub agent: String, + } + /// Opens a new agent thread with the provided branch diff for review. #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] #[action(namespace = agent)] From b1f456390942873767fcc16befdc638b550b9c1b Mon Sep 17 00:00:00 2001 From: Tautik Agrahari Date: Mon, 6 Jul 2026 17:49:35 +0530 Subject: [PATCH 084/197] docs: Add Tailwind LSP configuration section for Go (Templ) (#55255) Document how to configure the Tailwind CSS language server for Go (Templ). Ref #43969. Release Notes: - N/A --------- Co-authored-by: Kunall Banerjee --- docs/src/languages/go.md | 30 ++++++++++++++++++++++++++++++ docs/src/languages/tailwindcss.md | 1 + 2 files changed, 31 insertions(+) diff --git a/docs/src/languages/go.md b/docs/src/languages/go.md index c535acd80f0..c7ed310b3bd 100644 --- a/docs/src/languages/go.md +++ b/docs/src/languages/go.md @@ -234,3 +234,33 @@ In such case Zed won't spawn a new instance of Delve, as it opts to use an exist - Tree-sitter: [tree-sitter-go-work](https://github.com/d1y/tree-sitter-go-work) - Language Server: N/A + +## Using the Tailwind CSS Language Server with Templ + +To get all the features (autocomplete, linting, etc.) from the [Tailwind CSS language server](https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme) in [Templ](https://github.com/a-h/templ) files, you need to enable the language server for Templ and configure where it should look for CSS classes by adding the following to your `settings.json`: + +```json [settings] +{ + "languages": { + "Templ": { + "language_servers": ["tailwindcss-language-server", "..."] + } + }, + "lsp": { + "tailwindcss-language-server": { + "settings": { + "includeLanguages": { + "templ": "html" + }, + "experimental": { + "classRegex": ["class=\"([^\"]*)\""] + } + } + } + } +} +``` + +> Note: Unlike other languages, you need to tell Tailwind to treat `.templ` files as HTML explicitly. + +This gives you Tailwind CSS completions inside `class="..."` attributes in your `.templ` files. diff --git a/docs/src/languages/tailwindcss.md b/docs/src/languages/tailwindcss.md index 4644549ea0b..2c95d85a6ca 100644 --- a/docs/src/languages/tailwindcss.md +++ b/docs/src/languages/tailwindcss.md @@ -15,6 +15,7 @@ Languages which can be used with Tailwind CSS in Zed: - [CSS](./css.md) - [ERB](./ruby.md#using-the-tailwind-css-language-server-with-ruby) - [Gleam](./gleam.md#using-the-tailwind-css-language-server-with-gleam) +- [Go (Templ)](./go.md#using-the-tailwind-css-language-server-with-templ) - [HEEx](./elixir.md#using-the-tailwind-css-language-server-with-heex-templates) - [HTML](./html.md#using-the-tailwind-css-language-server-with-html) - [TypeScript](./typescript.md#using-the-tailwind-css-language-server-with-typescript) From 050e6f3407e0a003ca45a572b8d4132cef5b6dfd Mon Sep 17 00:00:00 2001 From: Yevhen Nakonechnyi Date: Mon, 6 Jul 2026 06:47:21 -0700 Subject: [PATCH 085/197] text_finder: Fix crash when dismissing the finder (#60437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective Fixes #60436 Pressing Escape (or triggering any workspace-level action that closes modals, such as `pane::DeploySearch` / `search::NewSearch`) while the Text Finder was open crashes the app with: ``` cannot read workspace::Workspace while it is already being updated crates/gpui/src/app/entity_map.rs:164 ``` `TextFinder::on_before_dismiss` read the `Workspace` entity (`workspace.read(cx).database_id()`) to persist the last search query. Dismissal can be initiated from inside a `workspace.register_action` handler — e.g. `buffer_search`'s `SearchActionsRegistrar` calls `workspace.hide_modal(...)` while the `Workspace` entity is leased — so the modal layer invokes `on_before_dismiss` synchronously under that lease, and the read trips GPUI's re-entrancy guard. Since the finder seeds the last query on open, the query is non-empty immediately, making the crash reproducible on the very first Escape. Dump file analysis confirms the diagnosis. ## Solution Stop reading the `Workspace` entity in the dismiss path. Both places that create the modal (`TextFinder::open` and `TextFinder::open_from_project_search`) already run inside `workspace.update_in`, where `workspace.database_id()` is a plain field access on `&mut Workspace`. Capture the `Option` there, store it on `TextFinder`, and have `on_before_dismiss` use the stored id. The dismiss path now touches no entity that can be mid-update, so it is safe regardless of which code path initiates `hide_modal`. The remaining reads in `on_before_dismiss` (`picker` and its query editor) are never leased in any dismissal chain. The `id` is captured once at creation; a workspace that gains a database id during the modal's lifetime would persist under `None` (i.e. skip persistence), which matches the previous behavior for unpersisted workspaces. ## Testing - Verified the fix by code review and re-tested in a rebuilt bundle (local build). - To reproduce/verify: open the Text Finder (`text_finder::Toggle`), type a query (or rely on a seeded one), then press Escape with a binding that resolves to `editor::Cancel`, or press `cmd-shift-f` (`DeploySearch`) while the finder is open. Before this change the app crashes. After it, the modal dismisses and the query is persisted. - Tested on macOS 26.5.1 (arm64); the affected code is platform-independent. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks — no unsafe blocks - [x] The content adheres to Zed's UI standards — no UI changes - [x] Tests cover the new/changed behavior — added - [x] Performance impact has been considered and is acceptable — one `Option` copy at modal creation --- Release Notes: - Fixed a crash when closing the Text Finder while a workspace action (e.g. Escape via `editor::Cancel`, or deploying search) triggered the dismissal --- crates/search/src/text_finder.rs | 86 +++++++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 7 deletions(-) diff --git a/crates/search/src/text_finder.rs b/crates/search/src/text_finder.rs index 500f8e4fe9e..06679887155 100644 --- a/crates/search/src/text_finder.rs +++ b/crates/search/src/text_finder.rs @@ -34,6 +34,7 @@ actions!(text_finder, [ToProjectSearch, Fold, Unfold, ToggleFoldAll]); pub struct TextFinder { picker: Entity>, init_modifiers: Option, + workspace_id: Option, _subscription: Subscription, } @@ -158,8 +159,9 @@ impl TextFinder { workspace .update_in(cx, |workspace, window, cx| { remove_project_search_tab(project_search_item_id, workspace, window, cx); + let workspace_id = workspace.database_id(); workspace.toggle_modal(window, cx, |window, cx| { - Self::new(delegate, None, window, cx) + Self::new(delegate, None, workspace_id, window, cx) }); }) .ok(); @@ -380,8 +382,9 @@ impl TextFinder { let delegate = delegate_task.await; workspace .update_in(cx, |workspace, window, cx| { + let workspace_id = workspace.database_id(); workspace.toggle_modal(window, cx, |window, cx| { - Self::new(delegate, seed_query, window, cx) + Self::new(delegate, seed_query, workspace_id, window, cx) }); }) .ok(); @@ -391,6 +394,7 @@ impl TextFinder { fn new( delegate: Delegate, seed_query: Option, + workspace_id: Option, window: &mut Window, cx: &mut Context, ) -> Self { @@ -418,6 +422,7 @@ impl TextFinder { Self { picker, init_modifiers: window.modifiers().modified().then_some(window.modifiers()), + workspace_id, _subscription: subscription, } } @@ -450,11 +455,7 @@ impl ModalView for TextFinder { let query = picker.query(cx); if !query.is_empty() { let options = picker.delegate.search_options; - let workspace_id = self - .weak_workspace(cx) - .upgrade() - .and_then(|workspace| workspace.read(cx).database_id()); - store_last_search(workspace_id, query, options, cx); + store_last_search(self.workspace_id, query, options, cx); } DismissDecision::Dismiss(true) } @@ -478,3 +479,74 @@ pub struct SearchMatch { pub line_text: String, pub line_number: u32, } + +#[cfg(test)] +mod tests { + use gpui::{TestAppContext, VisualTestContext}; + use project::{FakeFs, Project}; + use serde_json::json; + use settings::SettingsStore; + use util::path; + use workspace::MultiWorkspace; + + use super::*; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings = SettingsStore::test(cx); + cx.set_global(settings); + + theme_settings::init(theme::LoadThemes::JustBase, cx); + + editor::init(cx); + crate::init(cx); + }); + } + + /// Dismissal can be initiated from inside a workspace update: workspace-level + /// action handlers (e.g. buffer search's `SearchActionsRegistrar`) call + /// `Workspace::hide_modal` while the workspace entity is leased, which runs + /// `on_before_dismiss` synchronously under that lease. Reading the workspace + /// entity there panics with "cannot read workspace::Workspace while it is + /// already being updated", so this test dismisses the finder the same way. + #[gpui::test] + async fn test_dismiss_from_within_workspace_update(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/dir"), json!({"one.rs": "const ONE: usize = 1;"})) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = window + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + + // Seed a query: the last-search persistence in `on_before_dismiss` (the + // code path that read the workspace entity) only runs when the query is + // non-empty, which is the common case in practice since the finder seeds + // the previous query on open. + let seed_query = SearchSeed { + query: "ONE".to_string(), + options: None, + }; + workspace + .update_in(cx, |_, window, cx| { + TextFinder::open(Some(seed_query), window, cx) + }) + .await; + + workspace.update(cx, |workspace, cx| { + assert!(workspace.active_modal::(cx).is_some()); + }); + + workspace.update_in(cx, |workspace, window, cx| { + assert!(workspace.hide_modal(window, cx)); + }); + + workspace.update(cx, |workspace, cx| { + assert!(workspace.active_modal::(cx).is_none()); + }); + } +} From 2b0a83ea817d8fd046a3fe4ef92b229e442ab8c6 Mon Sep 17 00:00:00 2001 From: guopenghui Date: Mon, 6 Jul 2026 23:00:36 +0800 Subject: [PATCH 086/197] picker: Give delegate the initial preview layout (#60007) ## Objective - Provide initial preview layout information for the picker delegate. This ensures that when match items with different styles need to be displayed under horizontal or vertical layouts, they render correctly on the first display. ## Solution - Pass the initial layout information to the delegate in the picker constructor ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - N/A --- crates/picker/src/picker.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 2f9d0ad3c4f..7915e6fd001 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -547,6 +547,9 @@ impl Picker { actions_menu_handle: PopoverMenuHandle::default(), reopenable: true, }; + // give delegate the initial preview layout + this.delegate + .preview_layout_changed(matches!(initial_layout, preview::Layout::Right)); if this.reopenable { let focus_handle = this.focus_handle(cx); workspace::register_reopenable_picker(&focus_handle, cx); From e58a02d97409c2ecdbeaee7a936131ccc8f9dc16 Mon Sep 17 00:00:00 2001 From: David Alecrim <35930364+davidalecrim1@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:21:32 -0300 Subject: [PATCH 087/197] Fix text finder crash from unbounded memory use (#60377) - Fixes #60238. - The text finder could spike memory into the tens of gigabytes and crash the app. The finder was copying the full matched line into every match it kept, so memory grew with the number of matches times the size of each line. Enough matches carrying enough text and the process runs out of memory. ## Solution - Stop storing line text on matches. Each match now keeps only its position and column, and the text shown in a row is built lazily for the visible rows, using a bounded slice around the match rather than the whole line. Line boundaries come from the buffer's line index instead of re-scanning the content per match. - Cap the number of matches the finder builds while streaming results, using the same ceiling project search already applies. This keeps the finder from ever assembling an unbounded match list on the UI thread. - Behavior is unchanged: every occurrence is still its own row, jumping to a match lands on the exact line and column, and the rendered text and highlighting look the same. The finder just no longer holds text proportional to the match count. ## Testing - Added tests covering the new behavior: the match count is capped while streaming, every occurrence below the ceiling still becomes its own match at the correct line and column, and the rendered slice around a match stays bounded on a huge line while still covering a whole short line. - Verified by hand with the reproduction from the issue. The screenshot below shows the memory usage and the matching against the issue data: Screenshot 2026-07-03 at 17 43 11 ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - Fixed a crash and high memory usage in the text finder. --- crates/project/src/project_search.rs | 2 +- crates/search/src/text_finder.rs | 3 +- crates/search/src/text_finder/delegate.rs | 249 ++++++++++++++++++---- 3 files changed, 208 insertions(+), 46 deletions(-) diff --git a/crates/project/src/project_search.rs b/crates/project/src/project_search.rs index 971581dcf1a..dd1d4575fe8 100644 --- a/crates/project/src/project_search.rs +++ b/crates/project/src/project_search.rs @@ -152,7 +152,7 @@ impl Search { } pub(crate) const MAX_SEARCH_RESULT_FILES: usize = 5_000; - pub(crate) const MAX_SEARCH_RESULT_RANGES: usize = 10_000; + pub const MAX_SEARCH_RESULT_RANGES: usize = 10_000; /// Prepares a project search run. The resulting [`SearchResultsHandle`] has to be used to specify whether you're interested in matching buffers /// or full search results. pub fn into_handle(mut self, query: SearchQuery, cx: &mut App) -> SearchResultsHandle { diff --git a/crates/search/src/text_finder.rs b/crates/search/src/text_finder.rs index 06679887155..86f4a79f759 100644 --- a/crates/search/src/text_finder.rs +++ b/crates/search/src/text_finder.rs @@ -475,8 +475,7 @@ pub struct SearchMatch { pub buffer: Entity, pub anchor_range: Range, pub range: Range, - pub relative_range: Range, - pub line_text: String, + pub match_start_byte_column: u32, pub line_number: u32, } diff --git a/crates/search/src/text_finder/delegate.rs b/crates/search/src/text_finder/delegate.rs index 4d4b0717479..63068b1a9ed 100644 --- a/crates/search/src/text_finder/delegate.rs +++ b/crates/search/src/text_finder/delegate.rs @@ -38,7 +38,7 @@ use gpui::{ use gpui::{Entity, FocusHandle}; use language::{Buffer, LanguageAwareStyling}; use picker::{Picker, PickerDelegate}; -use project::{Project, ProjectPath}; +use project::{Project, ProjectPath, Search}; use project::{SearchResults, search::SearchQuery, search::SearchResult}; use settings::Settings; use smol::future::yield_now; @@ -130,27 +130,15 @@ fn multibuffer_ranges_to_search_matches<'a>( let start_offset: usize = buffer_snapshot.summary_for_anchor(&text_range.start); let end_offset: usize = buffer_snapshot.summary_for_anchor(&text_range.end); - let line_number = buffer_snapshot.offset_to_point(start_offset).row + 1; - - let text = buffer_snapshot.text(); - let line_start = text[..start_offset].rfind('\n').map(|i| i + 1).unwrap_or(0); - let line_end = text[start_offset..] - .find('\n') - .map(|i| start_offset + i) - .unwrap_or(text.len()); - let line_text = text[line_start..line_end].to_string(); - - let relative_start = start_offset - line_start; - let relative_end = end_offset - line_start; + let point = buffer_snapshot.offset_to_point(start_offset); Some(SearchMatch { path, buffer, anchor_range: text_range, range: start_offset..end_offset, - relative_range: relative_start..relative_end, - line_text, - line_number, + match_start_byte_column: point.column, + line_number: point.row + 1, }) }) } @@ -476,7 +464,7 @@ impl Delegate { }; let path = selected_match.path.clone(); let line_number = selected_match.line_number; - let column = selected_match.relative_range.start as u32; + let column = selected_match.match_start_byte_column; let Some(workspace) = self.project_search_view.read(cx).workspace.upgrade() else { return; }; @@ -594,6 +582,7 @@ const SEARCH_DEBOUNCE_MS: u64 = 100; const CLICK_THRESHOLD_MS: u128 = 50; const DOUBLE_CLICK_THRESHOLD_MS: u128 = 300; const SEARCH_RESULTS_BATCH_SIZE: usize = 256; +const MAX_MATCH_CONTEXT_BYTES: usize = 512; impl PickerDelegate for Delegate { type ListItem = AnyElement; @@ -826,7 +815,7 @@ impl PickerDelegate for Delegate { let path = selected_match.path.clone(); let line_number = selected_match.line_number; - let column = selected_match.relative_range.start as u32; + let column = selected_match.match_start_byte_column; let Some(workspace) = self.project_search_view.read(cx).workspace.upgrade() else { return; @@ -1052,6 +1041,11 @@ async fn stream_results_to_picker( .ready_chunks(SEARCH_RESULTS_BATCH_SIZE) ); + // Project search enforces its ranges cap per file, + // so one minified line slips through uncapped; cap it here. + let cap = Search::MAX_SEARCH_RESULT_RANGES; + let mut total_matches = 0; + let mut clear_existing = matches!(imported_matches, ImportedMatches::No); while let Some(results) = results_stream.next().await { if cancel_flag.load(std::sync::atomic::Ordering::SeqCst) { @@ -1064,8 +1058,14 @@ async fn stream_results_to_picker( for result in results { match result { SearchResult::Buffer { buffer, ranges } => { - let matches = Delegate::process_search_result(&buffer, &ranges, cx); + let remaining = cap.saturating_sub(total_matches + batch_matches.len()); + let capped = ranges.len().min(remaining); + let matches = Delegate::process_search_result(&buffer, &ranges[..capped], cx); batch_matches.extend(matches); + if capped < ranges.len() { + limit_reached = true; + break; + } } SearchResult::LimitReached => { limit_reached = true; @@ -1074,6 +1074,8 @@ async fn stream_results_to_picker( } } + total_matches += batch_matches.len(); + picker .update(cx, |picker, cx| { let delegate = &mut picker.delegate; @@ -1115,6 +1117,29 @@ async fn stream_results_to_picker( None } +/// Byte range around the match to render: a bounded slice of the matched line so rendering never scales with line length. +fn matched_line_window( + snapshot: &language::BufferSnapshot, + match_range: &Range, + column: u32, +) -> Range { + let line_start = match_range.start.saturating_sub(column as usize); + let row = snapshot.offset_to_point(match_range.start).row; + let line_end = snapshot.point_to_offset(text::Point::new(row, snapshot.line_len(row))); + let start = snapshot.clip_offset( + match_range + .start + .saturating_sub(MAX_MATCH_CONTEXT_BYTES) + .max(line_start), + text::Bias::Left, + ); + let end = snapshot.clip_offset( + (match_range.end + MAX_MATCH_CONTEXT_BYTES).min(line_end), + text::Bias::Right, + ); + start..end +} + /// Renders the matched source line with syntax highlighting, overlaying the /// search match with a highlighted background and bold weight. fn render_matched_line(search_match: &SearchMatch, cx: &App) -> StyledText { @@ -1129,23 +1154,40 @@ fn render_matched_line(search_match: &SearchMatch, cx: &App) -> StyledText { line_height: relative(1.), ..Default::default() }; - let original_line = &search_match.line_text; - let line_text = original_line.trim_start(); - let trim_offset = original_line.len() - line_text.len(); - let search_match_style = HighlightStyle { background_color: Some(cx.theme().colors().search_match_background), font_weight: Some(gpui::FontWeight::BOLD), ..Default::default() }; - let line_start_abs = search_match.range.start - search_match.relative_range.start; - let visible_start_abs = line_start_abs + trim_offset; - let visible_end_abs = line_start_abs + original_line.len(); + let snapshot = search_match.buffer.read(cx).snapshot(); + + // Render a bounded window around the match, not the whole line, + // so a minified single-line file stays cheap. + let line_start_abs = search_match + .range + .start + .saturating_sub(search_match.match_start_byte_column as usize); + let window = matched_line_window( + &snapshot, + &search_match.range, + search_match.match_start_byte_column, + ); + let window_text: String = snapshot.text_for_range(window.clone()).collect(); + + // Trim leading indentation only when the window starts at the line start; + // a mid-line window already begins on content. + let trim_offset = if window.start == line_start_abs { + window_text.len() - window_text.trim_start().len() + } else { + 0 + }; + let visible_start_abs = window.start + trim_offset; + let visible_end_abs = window.end; + let line_text = &window_text[trim_offset..]; // Syntax highlights for the visible (trimmed) portion of the line, with // ranges relative to the start of the rendered text. - let snapshot = search_match.buffer.read(cx).snapshot(); let syntax_theme = cx.theme().syntax(); let mut syntax_highlights: Vec<(Range, HighlightStyle)> = Vec::new(); let mut current_offset = 0; @@ -1235,23 +1277,11 @@ impl Delegate { worktree_id: f.worktree_id(cx), path: f.path().clone(), }); - let text = buf.text(); - let mut matches = Vec::new(); for anchor_range in ranges { let start_offset: usize = buf.summary_for_anchor(&anchor_range.start); let end_offset: usize = buf.summary_for_anchor(&anchor_range.end); - let match_row = buf.offset_to_point(start_offset).row; - let line_number = match_row + 1; - let line_start = text[..start_offset].rfind('\n').map(|i| i + 1).unwrap_or(0); - let line_end = text[start_offset..] - .find('\n') - .map(|i| start_offset + i) - .unwrap_or(text.len()); - let line_text = text[line_start..line_end].to_string(); - - let relative_start = start_offset - line_start; - let relative_end = end_offset - line_start; + let point = buf.offset_to_point(start_offset); if let Some(path) = &path { matches.push(SearchMatch { @@ -1259,9 +1289,8 @@ impl Delegate { buffer: buffer.clone(), anchor_range: anchor_range.clone(), range: start_offset..end_offset, - relative_range: relative_start..relative_end, - line_text, - line_number, + match_start_byte_column: point.column, + line_number: point.row + 1, }); } } @@ -1269,3 +1298,137 @@ impl Delegate { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{AppContext, TestAppContext}; + use project::search::{SearchQuery, SearchResult}; + use project::{FakeFs, Project}; + use serde_json::json; + use settings::SettingsStore; + use util::path; + use util::paths::PathMatcher; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings = SettingsStore::test(cx); + cx.set_global(settings); + theme_settings::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + crate::init(cx); + }); + } + + async fn project_with_file(cx: &mut TestAppContext, contents: String) -> Entity { + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/dir"), json!({ "sample.js": contents })) + .await; + Project::test(fs, [path!("/dir").as_ref()], cx).await + } + + #[gpui::test] + async fn test_finder_caps_matches_on_long_line(cx: &mut TestAppContext) { + use workspace::MultiWorkspace; + + init_test(cx); + + let line = "return ".repeat(Search::MAX_SEARCH_RESULT_RANGES * 2); + let project = project_with_file(cx, line).await; + + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + + let delegate = window + .update(cx, |_mw, window, cx| { + workspace.update(cx, |workspace, cx| Delegate::new(workspace, window, cx)) + }) + .unwrap() + .await; + let picker = window + .update(cx, |_mw, window, cx| { + cx.new(|cx| Picker::list(delegate, window, cx)) + }) + .unwrap(); + + window + .update(cx, |_mw, window, cx| { + picker.update(cx, |picker, cx| picker.set_query("return", window, cx)) + }) + .unwrap(); + + // Search is debounced; advance past the debounce and let results stream in. + cx.executor() + .advance_clock(std::time::Duration::from_millis(SEARCH_DEBOUNCE_MS + 50)); + cx.run_until_parked(); + + picker.read_with(cx, |picker, _| { + assert_eq!( + picker.delegate.matches.len(), + Search::MAX_SEARCH_RESULT_RANGES + ); + }); + } + + #[gpui::test] + async fn test_builds_one_match_per_occurrence(cx: &mut TestAppContext) { + init_test(cx); + + let line = "return ".repeat(2_000); + let expected_matches = line.matches("return").count(); + let project = project_with_file(cx, line).await; + + let query = SearchQuery::text( + "return", + false, + false, + false, + PathMatcher::default(), + PathMatcher::default(), + false, + None, + ) + .unwrap(); + + let search = project.update(cx, |project, cx| project.search(query, cx)); + let async_cx = cx.to_async(); + let mut matches = Vec::new(); + while let Ok(SearchResult::Buffer { buffer, ranges }) = search.rx.recv().await { + matches.extend(Delegate::process_search_result(&buffer, &ranges, &async_cx)); + } + + assert_eq!(matches.len(), expected_matches); + assert!(matches.iter().all(|m| m.line_number == 1)); + assert!( + matches + .windows(2) + .all(|pair| pair[0].match_start_byte_column < pair[1].match_start_byte_column) + ); + } + + #[gpui::test] + fn test_matched_line_window_is_bounded(cx: &mut gpui::TestAppContext) { + let long_line = "abcdefghij".repeat(1_000_000); + let buffer = cx.new(|cx| language::Buffer::local(long_line, cx)); + buffer.read_with(cx, |buffer, _| { + let snapshot = buffer.snapshot(); + let match_range = 5_000_000..5_000_003; + let column = match_range.start as u32; // single line, so column equals the offset + let window = matched_line_window(&snapshot, &match_range, column); + + assert!(window.start <= match_range.start && window.end >= match_range.end); + assert!(window.len() <= 2 * MAX_MATCH_CONTEXT_BYTES + match_range.len()); + }); + + let buffer = cx.new(|cx| language::Buffer::local(" let foo = bar;", cx)); + buffer.read_with(cx, |buffer, _| { + let snapshot = buffer.snapshot(); + let match_range = 8..11; // "foo" + let window = matched_line_window(&snapshot, &match_range, match_range.start as u32); + assert_eq!(window, 0..snapshot.len()); + }); + } +} From f16b46419dc84b357afe2ed0cd187440de6e9c7a Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:46:44 -0300 Subject: [PATCH 088/197] Improve diff tabs toolbar design (#60464) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds a bunch of design improvements to the toolbar of (tab) views that display diffs: the uncommitted changes, branch diff, and agent diff tabs. This involves adjusting spacing, sizing, and other small tweaks across all of these views, including touching up the divider component API a little bit, adjusting Git icons design, adding diff stat numbers to the uncommitted changes tab so its consistent with the others, and more (e.g., moving the split buttons into its own component to ensure they look the same across all uses). Ended up also going on a slight de-tour to make all uses of the split button in the Git panel consistent design-wise. All in all, this is just tidying up the design of all of these surfaces that are all relatively similar. | Branch Diff | Uncommitted Diff | Single-file Diff | Git Panel | |--------|--------|--------|--------| | Screenshot 2026-07-06 at 11  25@2x | Screenshot 2026-07-06 at 11  25 2@2x | Screenshot 2026-07-06 at 11  26@2x | Screenshot 2026-07-06 at 11  26 2@2x | Release Notes: - Added diff stat numbers to the uncommitted changes view. --- assets/icons/square_dot.svg | 4 +- assets/icons/square_minus.svg | 4 +- assets/icons/square_plus.svg | 6 +- crates/agent_ui/src/agent_diff.rs | 8 +- .../src/conversation_view/thread_view.rs | 2 +- crates/editor/src/editor.rs | 4 +- crates/editor/src/element.rs | 1 + crates/editor/src/element/header.rs | 85 ++--- crates/editor/src/split.rs | 133 +++++++- crates/git_ui/src/commit_modal.rs | 123 ++++--- crates/git_ui/src/git_panel.rs | 167 +++++----- crates/git_ui/src/git_ui.rs | 165 ++++++---- crates/git_ui/src/project_diff.rs | 228 +++++++------ crates/git_ui/src/solo_diff_view.rs | 311 ++++++++---------- crates/picker/src/footer.rs | 2 +- crates/search/src/buffer_search.rs | 146 +------- .../ui/src/components/button/split_button.rs | 20 +- crates/ui/src/components/divider.rs | 22 +- 18 files changed, 697 insertions(+), 734 deletions(-) diff --git a/assets/icons/square_dot.svg b/assets/icons/square_dot.svg index 72b32734399..a56ced30861 100644 --- a/assets/icons/square_dot.svg +++ b/assets/icons/square_dot.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/square_minus.svg b/assets/icons/square_minus.svg index 5ba458e8b53..31b2c97d74b 100644 --- a/assets/icons/square_minus.svg +++ b/assets/icons/square_minus.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/square_plus.svg b/assets/icons/square_plus.svg index 063c7dbf826..88ada58011f 100644 --- a/assets/icons/square_plus.svg +++ b/assets/icons/square_plus.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index ba731ec3b9e..9f51e53b292 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -28,7 +28,7 @@ use std::{ ops::Range, sync::Arc, }; -use ui::{CommonAnimationExt, IconButtonShape, KeyBinding, Tooltip, prelude::*, vertical_divider}; +use ui::{CommonAnimationExt, Divider, IconButtonShape, KeyBinding, Tooltip, prelude::*}; use util::ResultExt; use workspace::{ Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, @@ -1099,7 +1099,7 @@ impl Render for AgentDiffToolbar { }), ) .into_any_element(), - vertical_divider().into_any_element(), + Divider::vertical().into_any_element(), h_flex() .gap_0p5() .child( @@ -1141,7 +1141,7 @@ impl Render for AgentDiffToolbar { .mr_1() .gap_1() .children(content) - .child(vertical_divider()) + .child(Divider::vertical()) .when_some(editor.read(cx).workspace(), |this, _workspace| { this.child( IconButton::new("review", IconName::ListTodo) @@ -1158,7 +1158,7 @@ impl Render for AgentDiffToolbar { }), ) }) - .child(vertical_divider()) + .child(Divider::vertical()) .on_action({ let editor = editor.clone(); move |_action: &OpenAgentDiff, window, cx| { diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index c2f06da5261..90ea600ff5f 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -4913,7 +4913,7 @@ impl ThreadView { ); }), ) - .child(Divider::vertical().h_4()) + .child(Divider::vertical()) .into_any_element(), ) } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index d2eb44ecc87..1d224ad40b4 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -103,7 +103,7 @@ pub use editor_settings::{ }; pub use element::{ CursorLayout, EditorElement, HighlightedRange, HighlightedRangeLine, PointForPosition, - render_breadcrumb_text, + file_status_label_color, render_breadcrumb_text, }; pub use git::blame::BlameRenderer; pub(crate) use git::{DiffHunkKey, StoredReviewComment}; @@ -124,7 +124,7 @@ pub use multi_buffer::{ MultiBufferOffset, MultiBufferOffsetUtf16, MultiBufferSnapshot, PathKey, RowInfo, ToOffset, ToPoint, }; -pub use split::{SplittableEditor, ToggleSplitDiff}; +pub use split::{DiffStyleControls, SplittableEditor, ToggleSplitDiff}; pub use split_editor_view::SplitEditorView; pub use text::Bias; diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 80f6bf7fab9..e5836446cee 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -3,6 +3,7 @@ mod mouse; #[cfg(test)] pub(crate) use header::StickyHeader; +pub use header::file_status_label_color; pub(crate) use header::{header_jump_data, render_buffer_header}; use crate::{ diff --git a/crates/editor/src/element/header.rs b/crates/editor/src/element/header.rs index 9340ee6ac11..a6611b0c90a 100644 --- a/crates/editor/src/element/header.rs +++ b/crates/editor/src/element/header.rs @@ -21,7 +21,7 @@ use text::BufferId; use theme::ActiveTheme; use ui::{ ButtonLike, ContextMenu, DiffStat, Indicator, KeyBinding, Tooltip, prelude::*, - right_click_menu, text_for_keystroke, + right_click_menu, text_for_keystroke, utils::WithRemSize, }; use util::ResultExt; use workspace::{ItemHandle, ItemSettings, OpenInTerminal, OpenTerminal, RevealInProjectPanel}; @@ -691,6 +691,9 @@ pub(crate) fn render_buffer_header( let opaque_window = cx.theme().window_background_appearance() == WindowBackgroundAppearance::Opaque; + let show_open_file_button = + can_open_excerpts && relative_path.is_some() && (is_selected || header_hovered); + let header = div() .id(("buffer-header", buffer_id.to_proto())) .on_hover(move |hovered, _window, cx| { @@ -713,7 +716,6 @@ pub(crate) fn render_buffer_header( .pr_2() .rounded_sm() .gap_1p5() - .when(is_sticky && opaque_window, |el| el.shadow_md()) .border_1() .map(|border| { let border_color = @@ -724,10 +726,9 @@ pub(crate) fn render_buffer_header( }; border.border_color(border_color) }) - .when(opaque_window, |el| { - el.bg(colors.editor_subheader_background) - }) - .hover(|style| style.bg(colors.element_hover)) + .when(is_sticky && opaque_window, |s| s.shadow_md()) + .when(opaque_window, |s| s.bg(colors.editor_subheader_background)) + .hover(|s| s.bg(colors.element_hover)) .map(|header| { let editor = editor.clone(); let buffer_id = for_excerpt.buffer_id(); @@ -906,46 +907,46 @@ pub(crate) fn render_buffer_header( }) }, )) - .when_some(diff_stat, |this, (added, removed)| { - this.child( - div().flex_shrink_0().child( - DiffStat::new( + .child( + h_flex() + .gap_2() + .when_some(diff_stat, |this, (added, removed)| { + let ui_font_size = + theme_settings::ThemeSettings::get_global(cx) + .ui_font_size(cx); + this.child(WithRemSize::new(ui_font_size).child(DiffStat::new( ("buffer-header-diff-stat", buffer_id.to_proto()), added as usize, removed as usize, - ) - .label_size(LabelSize::Small), - ), - ) - }) - .when( - can_open_excerpts - && relative_path.is_some() - && (is_selected || header_hovered), - |this| { - this.child( - Button::new("open-file-button", "Open File") - .style(ButtonStyle::OutlinedGhost) - .when(is_selected, |this| { - this.key_binding(KeyBinding::for_action_in( - &OpenExcerpts, - &focus_handle, - cx, + ))) + }) + .when(show_open_file_button, |this| { + this.child( + Button::new("open-file-button", "Open File") + .style(ButtonStyle::OutlinedCustom( + cx.theme().colors().border.opacity(0.6), )) - }) - .on_click(window.listener_for(editor, { - let jump_data = jump_data.clone(); - move |editor, e: &ClickEvent, window, cx| { - editor.open_excerpts_common( - Some(jump_data.clone()), - e.modifiers().secondary(), - window, + .layer(ui::ElevationIndex::ElevatedSurface) + .when(is_selected, |this| { + this.key_binding(KeyBinding::for_action_in( + &OpenExcerpts, + &focus_handle, cx, - ); - } - })), - ) - }, + )) + }) + .on_click(window.listener_for(editor, { + let jump_data = jump_data.clone(); + move |editor, e: &ClickEvent, window, cx| { + editor.open_excerpts_common( + Some(jump_data.clone()), + e.modifiers().secondary(), + window, + cx, + ); + } + })), + ) + }), ) .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .on_click(window.listener_for(editor, { @@ -1086,7 +1087,7 @@ pub(crate) fn render_buffer_header( }) } -fn file_status_label_color(file_status: Option) -> Color { +pub fn file_status_label_color(file_status: Option) -> Color { file_status.map_or(Color::Default, |status| { if status.is_conflicted() { Color::Conflict diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index e568b886f73..dd34391e195 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -6,9 +6,10 @@ use std::{ use buffer_diff::{BufferDiff, BufferDiffSnapshot}; use collections::HashMap; +use fs::Fs; use gpui::{ - Action, AppContext as _, Entity, EventEmitter, Focusable, Font, Pixels, Subscription, - WeakEntity, canvas, + Action, Entity, EventEmitter, Focusable, Font, Pixels, Subscription, WeakEntity, canvas, + prelude::*, }; use itertools::Itertools; use language::{Buffer, Capability, HighlightedText}; @@ -18,12 +19,10 @@ use multi_buffer::{ }; use project::Project; use rope::Point; -use settings::{DiffViewStyle, SeedQuerySetting, Settings, SettingsStore}; +use settings::{DiffViewStyle, SeedQuerySetting, Settings, SettingsStore, update_settings_file}; use text::{Bias, BufferId, OffsetRangeExt as _, Patch, ToPoint as _}; -use ui::{ - App, Context, InteractiveElement as _, IntoElement as _, ParentElement as _, Render, - Styled as _, Window, div, -}; + +use ui::{Toggleable as _, Tooltip, prelude::*, render_modifiers}; use crate::{ display_map::CompanionExcerptPatch, @@ -41,7 +40,7 @@ use crate::{ actions::{DisableBreakpoint, EditLogBreakpoint, EnableBreakpoint, ToggleBreakpoint}, display_map::Companion, }; -use zed_actions::assistant::InlineAssist; +use zed_actions::{OpenSettingsAt, assistant::InlineAssist}; pub(crate) fn patches_for_lhs_range( rhs_snapshot: &MultiBufferSnapshot, @@ -401,6 +400,124 @@ fn patch_for_excerpt( #[action(namespace = editor)] pub struct ToggleSplitDiff; +/// Unified/split diff view toggle buttons, shared by the toolbars of every +/// diff view (project diff, branch diff, solo diff) so they can't drift apart. +#[derive(gpui::IntoElement)] +pub struct DiffStyleControls { + splittable_editor: Entity, +} + +impl DiffStyleControls { + pub fn new(splittable_editor: Entity) -> Self { + Self { splittable_editor } + } + + fn set_diff_view_style( + splittable_editor: &Entity, + diff_view_style: DiffViewStyle, + window: &mut Window, + cx: &mut App, + ) { + update_settings_file(::global(cx), cx, move |settings, _| { + settings.editor.diff_view_style = Some(diff_view_style); + }); + + splittable_editor.update(cx, |editor, cx| { + if editor.diff_view_style() != diff_view_style { + editor.toggle_split(&ToggleSplitDiff, window, cx); + } + }); + } +} + +impl RenderOnce for DiffStyleControls { + fn render(self, _window: &mut Window, cx: &mut App) -> impl gpui::IntoElement { + let editor = self.splittable_editor.read(cx); + let diff_view_style = editor.diff_view_style(); + let is_split_set = diff_view_style == DiffViewStyle::Split; + let is_split_pending = is_split_set && !editor.is_split(); + let min_columns = EditorSettings::get_global(cx).minimum_split_diff_width as u32; + + let split_icon = if is_split_pending { + IconName::DiffSplitAuto + } else { + IconName::DiffSplit + }; + + h_flex() + .gap_1() + .child( + IconButton::new("diff-style-unified", IconName::DiffUnified) + .icon_size(IconSize::Small) + .toggle_state(diff_view_style == DiffViewStyle::Unified) + .tooltip(Tooltip::text("Unified")) + .on_click({ + let splittable_editor = self.splittable_editor.clone(); + move |_, window, cx| { + Self::set_diff_view_style( + &splittable_editor, + DiffViewStyle::Unified, + window, + cx, + ); + } + }), + ) + .child( + IconButton::new("diff-style-split", split_icon) + .icon_size(IconSize::Small) + .toggle_state(is_split_set) + .tooltip(Tooltip::element(move |_, cx| { + let message = if is_split_pending { + format!("Split when wider than {} columns", min_columns).into() + } else { + SharedString::from("Split") + }; + + v_flex() + .child(message) + .child( + h_flex() + .gap_0p5() + .text_ui_sm(cx) + .text_color(Color::Muted.color(cx)) + .children(render_modifiers( + &gpui::Modifiers::secondary_key(), + PlatformStyle::platform(), + None, + Some(TextSize::Small.rems(cx).into()), + false, + )) + .child("click to change min width"), + ) + .into_any_element() + })) + .on_click({ + let splittable_editor = self.splittable_editor.clone(); + move |_, window, cx| { + if window.modifiers().secondary() { + window.dispatch_action( + OpenSettingsAt { + path: "minimum_split_diff_width".to_string(), + target: None, + } + .boxed_clone(), + cx, + ); + } else { + Self::set_diff_view_style( + &splittable_editor, + DiffViewStyle::Split, + window, + cx, + ); + } + } + }), + ) + } +} + pub struct SplittableEditor { rhs_multibuffer: Entity, rhs_editor: Entity, diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index 50090a559ca..06c554e7b6e 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -8,7 +8,8 @@ use git::{Amend, Commit, GenerateCommitMessage, Signoff}; use project::DisableAiSettings; use settings::Settings; use ui::{ - ContextMenu, KeybindingHint, PopoverMenu, PopoverMenuHandle, SplitButton, Tooltip, prelude::*, + ButtonLike, ContextMenu, ElevationIndex, KeybindingHint, PopoverMenu, PopoverMenuHandle, + SplitButton, Tooltip, prelude::*, }; use zed_actions::{DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize}; @@ -268,17 +269,14 @@ impl CommitModal { id: impl Into, keybinding_target: Option, ) -> impl IntoElement { + let menu_open = self.commit_menu_handle.is_deployed(); + PopoverMenu::new(id.into()) - .trigger( - ui::ButtonLike::new_rounded_right("commit-split-button-right") - .layer(ui::ElevationIndex::ModalSurface) - .size(ui::ButtonSize::None) - .child( - div() - .px_1() - .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)), - ), - ) + .with_handle(self.commit_menu_handle.clone()) + .trigger(crate::render_split_button_chevron_trigger( + "modal-commit-split-button-right", + menu_open, + )) .menu({ let git_panel_entity = self.git_panel.clone(); move |window, cx| { @@ -327,7 +325,10 @@ impl CommitModal { })) } }) - .with_handle(self.commit_menu_handle.clone()) + .offset(gpui::Point { + x: px(0.), + y: px(2.), + }) .anchor(Anchor::TopRight) } @@ -370,13 +371,12 @@ impl CommitModal { .unwrap_or_else(|| "".to_owned()); let branch_picker_button = Button::new("branch_picker_button", branch) + .label_size(LabelSize::Small) .start_icon( Icon::new(IconName::GitBranch) .size(IconSize::Small) .color(Color::Placeholder), ) - .style(ButtonStyle::Transparent) - .color(Color::Muted) .on_click(cx.listener(|_, _, window, cx| { window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx); })); @@ -401,6 +401,7 @@ impl CommitModal { x: px(0.0), y: px(-2.0), }); + let focus_handle = self.focus_handle(cx); let close_kb_hint = ui::KeyBinding::for_action(&menu::Cancel, cx).map(|close_kb| { @@ -409,13 +410,12 @@ impl CommitModal { h_flex() .group("commit_editor_footer") - .flex_none() - .w_full() - .items_center() - .justify_between() .w_full() .h(px(self.properties.footer_height)) + .w_full() .gap_1() + .flex_none() + .justify_between() .child( h_flex() .gap_1() @@ -430,64 +430,53 @@ impl CommitModal { .children(generate_commit_message) .children(co_authors), ) - .child(div().flex_1()) .child( h_flex() - .items_center() - .justify_end() - .flex_none() - .px_1() .gap_4() .child(close_kb_hint) .child(SplitButton::new( - ui::ButtonLike::new_rounded_left(ElementId::Name( - format!("split-button-left-{}", commit_label).into(), - )) - .layer(ui::ElevationIndex::ModalSurface) - .size(ui::ButtonSize::Compact) - .child( - div() - .child(Label::new(commit_label).size(LabelSize::Small)) - .mr_0p5(), - ) - .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| { - telemetry::event!("Git Committed", source = "Git Modal"); - this.git_panel.update(cx, |git_panel, cx| { - git_panel.commit_changes( - CommitOptions { - amend: is_amend_pending, - signoff: is_signoff_enabled, - allow_empty: false, - }, - window, - cx, - ) - }); - cx.emit(DismissEvent); - })) - .disabled(!can_commit) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |_window, cx| { - if can_commit { - Tooltip::with_meta_in( - tooltip, - Some(&git::Commit), - format!( - "git commit{}{}", - if is_amend_pending { " --amend" } else { "" }, - if is_signoff_enabled { " --signoff" } else { "" } - ), - &focus_handle.clone(), + ButtonLike::new_rounded_left(format!("split-button-left-{}", commit_label)) + .layer(ElevationIndex::ModalSurface) + .size(ButtonSize::Compact) + .disabled(!can_commit) + .child(Label::new(commit_label).size(LabelSize::Small).mr_0p5()) + .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| { + telemetry::event!("Git Committed", source = "Git Modal"); + this.git_panel.update(cx, |git_panel, cx| { + git_panel.commit_changes( + CommitOptions { + amend: is_amend_pending, + signoff: is_signoff_enabled, + allow_empty: false, + }, + window, cx, ) - } else { - Tooltip::simple(tooltip, cx) + }); + cx.emit(DismissEvent); + })) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + if can_commit { + Tooltip::with_meta_in( + tooltip, + Some(&git::Commit), + format!( + "git commit{}{}", + if is_amend_pending { " --amend" } else { "" }, + if is_signoff_enabled { " --signoff" } else { "" } + ), + &focus_handle.clone(), + cx, + ) + } else { + Tooltip::simple(tooltip, cx) + } } - } - }), + }), self.render_git_commit_menu( - ElementId::Name(format!("split-button-right-{}", commit_label).into()), + format!("split-button-right-{}", commit_label), Some(focus_handle), ) .into_any_element(), diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 134aad4bc69..e2b04699b22 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -79,8 +79,9 @@ use theme_settings::ThemeSettings; use time::OffsetDateTime; use ui::{ ButtonLike, Checkbox, ContextMenu, ContextMenuEntry, Divider, ElevationIndex, - IndentGuideColors, KeyBinding, PopoverMenu, ProjectEmptyState, RenderedIndentGuide, ScrollAxes, - Scrollbars, SplitButton, Tab, TintColor, Tooltip, WithScrollbar, prelude::*, + IndentGuideColors, KeyBinding, PopoverMenu, PopoverMenuHandle, ProjectEmptyState, + RenderedIndentGuide, ScrollAxes, Scrollbars, SplitButton, Tab, TintColor, Tooltip, + WithScrollbar, prelude::*, }; use util::paths::PathStyle; use util::{ResultExt, TryFutureExt, markdown::MarkdownInlineCode, maybe, rel_path::RelPath}; @@ -801,9 +802,11 @@ pub struct GitPanel { history_keyboard_nav: bool, _commit_message_buffer_subscription: Option, _repo_subscriptions: Vec, - _settings_subscription: Subscription, git_access: Option, + commit_menu_handle: PopoverMenuHandle, + changes_actions_menu_handle: PopoverMenuHandle, + remote_action_menu_handle: PopoverMenuHandle, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -1084,6 +1087,9 @@ impl GitPanel { _repo_subscriptions: Vec::new(), _settings_subscription, git_access: None, + commit_menu_handle: PopoverMenuHandle::default(), + changes_actions_menu_handle: PopoverMenuHandle::default(), + remote_action_menu_handle: PopoverMenuHandle::default(), }; this.schedule_update(window, cx); @@ -4867,21 +4873,14 @@ impl GitPanel { keybinding_target: Option, cx: &mut Context, ) -> impl IntoElement { + let menu_open = self.commit_menu_handle.is_deployed(); + PopoverMenu::new(id.into()) - .trigger( - ui::ButtonLike::new_rounded_right("commit-split-button-right") - .layer(ui::ElevationIndex::ModalSurface) - .size(ButtonSize::None) - .child( - h_flex() - .px_1() - .h_full() - .justify_center() - .border_l_1() - .border_color(cx.theme().colors().border) - .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)), - ), - ) + .trigger(crate::render_split_button_chevron_trigger( + "commit-split-button-right", + menu_open, + )) + .with_handle(self.commit_menu_handle.clone()) .menu({ let git_panel = cx.entity(); let has_previous_commit = self.head_commit(cx).is_some(); @@ -4923,6 +4922,10 @@ impl GitPanel { } }) .anchor(Anchor::TopRight) + .offset(gpui::Point { + x: px(0.), + y: px(2.), + }) } pub fn configure_commit_button(&self, cx: &mut Context) -> (bool, &'static str) { @@ -5008,19 +5011,16 @@ impl GitPanel { let has_unstaged_changes = self.has_unstaged_changes(); let has_new_changes = self.new_count > 0; let has_stash_items = self.stash_entries.entries.len() > 0; + let focus_handle = self.focus_handle.clone(); + let menu_open = self.changes_actions_menu_handle.is_deployed(); PopoverMenu::new(id.into()) - .trigger( - ui::ButtonLike::new_rounded_right("git-changes-actions-split-button-right") - .layer(ui::ElevationIndex::ModalSurface) - .size(ButtonSize::None) - .child( - div() - .px_1() - .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)), - ), - ) + .trigger(crate::render_split_button_chevron_trigger( + "changes-actions-split-button-right", + menu_open, + )) + .with_handle(self.changes_actions_menu_handle.clone()) .menu(move |window, cx| { Some(git_panel_context_menu( has_tracked_changes, @@ -5034,6 +5034,10 @@ impl GitPanel { )) }) .anchor(Anchor::TopRight) + .offset(gpui::Point { + x: px(0.), + y: px(2.), + }) } fn render_git_changes_actions_button(&self, cx: &mut Context) -> impl IntoElement { @@ -5158,6 +5162,7 @@ impl GitPanel { focus_handle, true, self.pending_remote_operation, + self.remote_action_menu_handle.clone(), )) }) .into_any_element(), @@ -5359,7 +5364,7 @@ impl GitPanel { Color::Default }; - div() + h_flex() .id("commit-wrapper") .on_hover(cx.listener(move |this, hovered, _, cx| { this.show_placeholders = @@ -5367,57 +5372,55 @@ impl GitPanel { cx.notify() })) .child(SplitButton::new( - ButtonLike::new_rounded_left(ElementId::Name( - format!("split-button-left-{}", title).into(), - )) - .layer(ElevationIndex::ModalSurface) - .size(ButtonSize::Compact) - .child( - Label::new(title) - .size(LabelSize::Small) - .color(label_color) - .mr_0p5(), - ) - .on_click({ - let git_panel = cx.weak_entity(); - move |_, window, cx| { - telemetry::event!("Git Committed", source = "Git Panel"); - git_panel - .update(cx, |git_panel, cx| { - git_panel.commit_changes( - CommitOptions { - amend, - signoff, - allow_empty: false, - }, - window, - cx, - ); - }) - .ok(); - } - }) - .disabled(!can_commit || self.modal_open) - .tooltip({ - let handle = commit_tooltip_focus_handle.clone(); - move |_window, cx| { - if can_commit { - Tooltip::with_meta_in( - tooltip, - Some(&git::Commit), - format!( - "git commit{}{}", - if amend { " --amend" } else { "" }, - if signoff { " --signoff" } else { "" } - ), - &handle.clone(), - cx, - ) - } else { - Tooltip::simple(tooltip, cx) + ButtonLike::new_rounded_left(format!("split-button-left-{}", title)) + .layer(ElevationIndex::ModalSurface) + .size(ButtonSize::Compact) + .disabled(!can_commit || self.modal_open) + .child( + Label::new(title) + .size(LabelSize::Small) + .color(label_color) + .mr_0p5(), + ) + .on_click({ + let git_panel = cx.weak_entity(); + move |_, window, cx| { + telemetry::event!("Git Committed", source = "Git Panel"); + git_panel + .update(cx, |git_panel, cx| { + git_panel.commit_changes( + CommitOptions { + amend, + signoff, + allow_empty: false, + }, + window, + cx, + ); + }) + .ok(); } - } - }), + }) + .tooltip({ + let handle = commit_tooltip_focus_handle.clone(); + move |_window, cx| { + if can_commit { + Tooltip::with_meta_in( + tooltip, + Some(&git::Commit), + format!( + "git commit{}{}", + if amend { " --amend" } else { "" }, + if signoff { " --signoff" } else { "" } + ), + &handle.clone(), + cx, + ) + } else { + Tooltip::simple(tooltip, cx) + } + } + }), self.render_git_commit_menu( ElementId::Name(format!("split-button-right-{}", title).into()), Some(commit_tooltip_focus_handle), @@ -5615,7 +5618,11 @@ impl GitPanel { GitPanelTab::Changes, ActivateChangesTab.boxed_clone(), )) - .child(Divider::vertical().color(ui::DividerColor::BorderFaded)) + .child( + Divider::vertical() + .color(ui::DividerColor::BorderFaded) + .h_full(), + ) .child(tab( ElementId::Name("history-tab".into()), active_tab != GitPanelTab::Changes, @@ -6515,7 +6522,7 @@ impl GitPanel { ) .separator() .action("Open Diff", menu::Confirm.boxed_clone()) - .action("Open Diff (File)", menu::SecondaryConfirm.boxed_clone()) + .action("Open File Diff", menu::SecondaryConfirm.boxed_clone()) .action("View File", ViewFile.boxed_clone()) .when(!is_created, |context_menu| { context_menu diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index a5dc98c5049..08192d70c0f 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -2,10 +2,6 @@ use anyhow::anyhow; use commit_modal::CommitModal; use editor::{Editor, actions::DiffClipboardWithSelectionData}; -use ui::{ - Color, Headline, HeadlineSize, Icon, IconName, IconSize, IntoElement, ParentElement, Render, - Styled, StyledExt, div, h_flex, rems, v_flex, -}; use workspace::{Toast, notifications::NotificationId}; mod blame_ui; @@ -23,7 +19,7 @@ use menu::{Cancel, Confirm}; use project::git_store::Repository; use project_diff::ProjectDiff; use time::OffsetDateTime; -use ui::prelude::*; +use ui::{ButtonLike, ContextMenu, ElevationIndex, PopoverMenuHandle, TintColor, prelude::*}; use workspace::{ ModalView, OpenMode, Workspace, notifications::{DetachAndPromptErr, NotifyTaskExt}, @@ -766,6 +762,7 @@ fn render_remote_button( keybinding_target: Option, show_fetch_button: bool, in_progress_operation: Option, + menu_handle: PopoverMenuHandle, ) -> Option { let id = id.into(); let upstream = branch.upstream.as_ref(); @@ -778,6 +775,7 @@ fn render_remote_button( keybinding_target, id, in_progress_operation, + menu_handle, )), (0, 0) => None, (ahead, 0) => Some(remote_button::render_push_button( @@ -785,6 +783,7 @@ fn render_remote_button( id, ahead, in_progress_operation, + menu_handle, )), (ahead, behind) => Some(remote_button::render_pull_button( keybinding_target, @@ -792,6 +791,7 @@ fn render_remote_button( ahead, behind, in_progress_operation, + menu_handle, )), }, Some(Upstream { @@ -801,11 +801,13 @@ fn render_remote_button( keybinding_target, id, in_progress_operation, + menu_handle, )), None => Some(remote_button::render_publish_button( keybinding_target, id, in_progress_operation, + menu_handle, )), } } @@ -813,12 +815,16 @@ fn render_remote_button( mod remote_button { use crate::git_panel::RemoteOperationKind; use gpui::{Action, Anchor, AnyView, ClickEvent, FocusHandle}; - use ui::{CommonAnimationExt, ContextMenu, PopoverMenu, SplitButton, Tooltip, prelude::*}; + use ui::{ + ButtonLike, CommonAnimationExt, ContextMenu, ElevationIndex, PopoverMenu, + PopoverMenuHandle, SplitButton, Tooltip, prelude::*, + }; pub fn render_fetch_button( keybinding_target: Option, id: SharedString, in_progress_operation: Option, + menu_handle: PopoverMenuHandle, ) -> SplitButton { split_button( id, @@ -828,6 +834,7 @@ mod remote_button { Some(IconName::ArrowCircle), keybinding_target.clone(), in_progress_operation, + menu_handle, move |_, window, cx| { window.dispatch_action(Box::new(git::Fetch), cx); }, @@ -848,6 +855,7 @@ mod remote_button { id: SharedString, ahead: u32, in_progress_operation: Option, + menu_handle: PopoverMenuHandle, ) -> SplitButton { split_button( id, @@ -857,6 +865,7 @@ mod remote_button { None, keybinding_target.clone(), in_progress_operation, + menu_handle, move |_, window, cx| { window.dispatch_action(Box::new(git::Push), cx); }, @@ -878,6 +887,7 @@ mod remote_button { ahead: u32, behind: u32, in_progress_operation: Option, + menu_handle: PopoverMenuHandle, ) -> SplitButton { split_button( id, @@ -887,6 +897,7 @@ mod remote_button { None, keybinding_target.clone(), in_progress_operation, + menu_handle, move |_, window, cx| { window.dispatch_action(Box::new(git::Pull), cx); }, @@ -906,6 +917,7 @@ mod remote_button { keybinding_target: Option, id: SharedString, in_progress_operation: Option, + menu_handle: PopoverMenuHandle, ) -> SplitButton { split_button( id, @@ -915,6 +927,7 @@ mod remote_button { Some(IconName::ExpandUp), keybinding_target.clone(), in_progress_operation, + menu_handle, move |_, window, cx| { window.dispatch_action(Box::new(git::Push), cx); }, @@ -934,6 +947,7 @@ mod remote_button { keybinding_target: Option, id: SharedString, in_progress_operation: Option, + menu_handle: PopoverMenuHandle, ) -> SplitButton { split_button( id, @@ -943,6 +957,7 @@ mod remote_button { Some(IconName::ExpandUp), keybinding_target.clone(), in_progress_operation, + menu_handle, move |_, window, cx| { window.dispatch_action(Box::new(git::Push), cx); }, @@ -986,18 +1001,16 @@ mod remote_button { fn render_git_action_menu( id: impl Into, keybinding_target: Option, + menu_handle: PopoverMenuHandle, ) -> impl IntoElement { + let menu_open = menu_handle.is_deployed(); + PopoverMenu::new(id.into()) - .trigger( - ui::ButtonLike::new_rounded_right("split-button-right") - .layer(ui::ElevationIndex::ModalSurface) - .size(ui::ButtonSize::None) - .child( - div() - .px_1() - .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)), - ), - ) + .trigger(crate::render_split_button_chevron_trigger( + "split-button-right", + menu_open, + )) + .with_handle(menu_handle) .menu(move |window, cx| { Some(ContextMenu::build(window, cx, |context_menu, _, _| { context_menu @@ -1015,6 +1028,10 @@ mod remote_button { })) }) .anchor(Anchor::TopRight) + .offset(gpui::Point { + x: px(0.), + y: px(2.), + }) } #[allow(clippy::too_many_arguments)] @@ -1026,6 +1043,7 @@ mod remote_button { left_icon: Option, keybinding_target: Option, in_progress_operation: Option, + menu_handle: PopoverMenuHandle, left_on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static, ) -> SplitButton { @@ -1033,7 +1051,6 @@ mod remote_button { h_flex() .ml_neg_px() .h(rems(0.875)) - .items_center() .overflow_hidden() .px_0p5() .child( @@ -1046,58 +1063,57 @@ mod remote_button { let should_render_counts = left_icon.is_none() && (ahead_count > 0 || behind_count > 0); let is_in_progress = in_progress_operation.is_some(); - let left = ui::ButtonLike::new_rounded_left(ElementId::Name( - format!("split-button-left-{}", id).into(), - )) - .layer(ui::ElevationIndex::ModalSurface) - .size(ui::ButtonSize::Compact) - .disabled(is_in_progress) - .when(should_render_counts, |this| { - this.child( - h_flex() - .ml_neg_0p5() - .when(behind_count > 0, |this| { - this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall)) - .child(count(behind_count)) - }) - .when(ahead_count > 0, |this| { - this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall)) - .child(count(ahead_count)) - }), - ) - }) - .when_some(left_icon, |this, left_icon| { - this.map(|this| { - if is_in_progress { - this.child( - Icon::new(IconName::LoadCircle) - .size(IconSize::XSmall) - .color(Color::Disabled) - .with_rotate_animation(2), - ) - } else { - this.child(Icon::new(left_icon).size(IconSize::XSmall)) - } + let left = ButtonLike::new_rounded_left(format!("split-button-left-{}", id)) + .layer(ElevationIndex::ModalSurface) + .size(ButtonSize::Compact) + .disabled(is_in_progress) + .when(should_render_counts, |this| { + this.child( + h_flex() + .ml_neg_0p5() + .when(behind_count > 0, |this| { + this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall)) + .child(count(behind_count)) + }) + .when(ahead_count > 0, |this| { + this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall)) + .child(count(ahead_count)) + }), + ) }) - }) - .child( - Label::new(left_label) - .size(LabelSize::Small) - .when(is_in_progress, |this| this.color(Color::Disabled)) - .mr_0p5(), - ) - .on_click(left_on_click) - .tooltip(move |window, cx| { - if let Some(operation) = in_progress_operation { - Tooltip::simple(in_progress_tooltip(operation), cx) - } else { - tooltip(window, cx) - } - }); + .when_some(left_icon, |this, left_icon| { + this.map(|this| { + if is_in_progress { + this.child( + Icon::new(IconName::LoadCircle) + .size(IconSize::XSmall) + .color(Color::Disabled) + .with_rotate_animation(2), + ) + } else { + this.child(Icon::new(left_icon).size(IconSize::XSmall)) + } + }) + }) + .child( + Label::new(left_label) + .size(LabelSize::Small) + .when(is_in_progress, |this| this.color(Color::Disabled)) + .mr_0p5(), + ) + .on_click(left_on_click) + .tooltip(move |window, cx| { + if let Some(operation) = in_progress_operation { + Tooltip::simple(in_progress_tooltip(operation), cx) + } else { + tooltip(window, cx) + } + }); let right = render_git_action_menu( - ElementId::Name(format!("split-button-right-{}", id).into()), + format!("split-button-right-{}", id), keybinding_target, + menu_handle, ) .into_any_element(); @@ -1105,6 +1121,25 @@ mod remote_button { } } +pub(crate) fn render_split_button_chevron_trigger( + id: impl Into, + menu_open: bool, +) -> ButtonLike { + let chevron_button_size = rems_from_px(20.); + let chevron_icon = if menu_open { + IconName::ChevronUp + } else { + IconName::ChevronDown + }; + + ButtonLike::new_rounded_right(id) + .layer(ElevationIndex::ModalSurface) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .width(chevron_button_size) + .height(chevron_button_size.into()) + .child(Icon::new(chevron_icon).size(IconSize::XSmall)) +} + /// A visual representation of a file's Git status. #[derive(IntoElement, RegisterComponent)] pub struct GitStatusIcon { diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 89f57e274f8..91f351bec29 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -40,7 +40,6 @@ use std::sync::Arc; use theme::ActiveTheme; use ui::{ CommonAnimationExt as _, DiffStat, Divider, KeyBinding, PopoverMenu, Tooltip, prelude::*, - vertical_divider, }; use util::{ResultExt as _, rel_path::RelPath}; use workspace::{ @@ -1739,16 +1738,59 @@ impl Render for ProjectDiffToolbar { let button_states = project_diff.read(cx).button_states(cx); let review_count = project_diff.read(cx).total_review_comment_count(); - h_group_xl() + let (additions, deletions) = project_diff.read(cx).calculate_changed_lines(cx); + let is_multibuffer_empty = project_diff.read(cx).multibuffer.read(cx).is_empty(); + + h_flex() .my_neg_1() .py_1() - .items_center() + .gap_1p5() .flex_wrap() .justify_between() + .when(!is_multibuffer_empty, |this| { + this.child(DiffStat::new( + "project-diff-stat", + additions as usize, + deletions as usize, + )) + .child(Divider::vertical().ml_1()) + }) + // n.b. the only reason these arrows are here is because we don't + // support "undo" for staging so we need a way to go back. .child( h_group_sm() - .when(button_states.selection, |el| { - el.child( + .child( + IconButton::new("up", IconName::ArrowUp) + .icon_size(IconSize::Small) + .disabled(!button_states.prev_next) + .tooltip(Tooltip::for_action_title_in( + "Go to Previous Hunk", + &GoToPreviousHunk, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&GoToPreviousHunk, window, cx) + })), + ) + .child( + IconButton::new("down", IconName::ArrowDown) + .icon_size(IconSize::Small) + .disabled(!button_states.prev_next) + .tooltip(Tooltip::for_action_title_in( + "Go to Next Hunk", + &GoToHunk, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&GoToHunk, window, cx) + })), + ), + ) + .child(Divider::vertical()) + .child( + h_group_sm() + .when(button_states.selection, |this| { + this.child( Button::new("stage", "Toggle Staged") .tooltip(Tooltip::for_action_title_in( "Toggle Staged", @@ -1761,127 +1803,83 @@ impl Render for ProjectDiffToolbar { })), ) }) - .when(!button_states.selection, |el| { - el.child( + .when(!button_states.selection, |this| { + this.child( Button::new("stage", "Stage") + .disabled(!button_states.stage) .tooltip(Tooltip::for_action_title_in( - "Stage and go to next hunk", + "Stage and Go to Next Hunk", &StageAndNext, &focus_handle, )) - .disabled( - !button_states.prev_next - && !button_states.stage_all - && !button_states.unstage_all, - ) .on_click(cx.listener(|this, _, window, cx| { this.dispatch_action(&StageAndNext, window, cx) })), ) .child( Button::new("unstage", "Unstage") + .disabled(!button_states.unstage) .tooltip(Tooltip::for_action_title_in( - "Unstage and go to next hunk", + "Unstage and Go to Next Hunk", &UnstageAndNext, &focus_handle, )) - .disabled( - !button_states.prev_next - && !button_states.stage_all - && !button_states.unstage_all, - ) .on_click(cx.listener(|this, _, window, cx| { this.dispatch_action(&UnstageAndNext, window, cx) })), ) }), ) - // n.b. the only reason these arrows are here is because we don't - // support "undo" for staging so we need a way to go back. - .child( - h_group_sm() - .child( - IconButton::new("up", IconName::ArrowUp) - .shape(ui::IconButtonShape::Square) + .child(Divider::vertical()) + .when( + button_states.unstage_all && !button_states.stage_all, + |this| { + this.child( + Button::new("unstage-all", "Unstage All") + .width(rems_from_px(80.)) .tooltip(Tooltip::for_action_title_in( - "Go to previous hunk", - &GoToPreviousHunk, + "Unstage All Changes", + &UnstageAll, &focus_handle, )) - .disabled(!button_states.prev_next) - .on_click(cx.listener(|this, _, window, cx| { - this.dispatch_action(&GoToPreviousHunk, window, cx) - })), + .on_click( + cx.listener(|this, _, window, cx| this.unstage_all(window, cx)), + ), ) - .child( - IconButton::new("down", IconName::ArrowDown) - .shape(ui::IconButtonShape::Square) - .tooltip(Tooltip::for_action_title_in( - "Go to next hunk", - &GoToHunk, - &focus_handle, - )) - .disabled(!button_states.prev_next) - .on_click(cx.listener(|this, _, window, cx| { - this.dispatch_action(&GoToHunk, window, cx) - })), - ), + }, ) - .child(vertical_divider()) - .child( - h_group_sm() - .when( - button_states.unstage_all && !button_states.stage_all, - |el| { - el.child( - Button::new("unstage-all", "Unstage All") - .tooltip(Tooltip::for_action_title_in( - "Unstage all changes", - &UnstageAll, - &focus_handle, - )) - .on_click(cx.listener(|this, _, window, cx| { - this.unstage_all(window, cx) - })), - ) - }, - ) - .when( - !button_states.unstage_all || button_states.stage_all, - |el| { - el.child( - // todo make it so that changing to say "Unstaged" - // doesn't change the position. - div().child( - Button::new("stage-all", "Stage All") - .disabled(!button_states.stage_all) - .tooltip(Tooltip::for_action_title_in( - "Stage all changes", - &StageAll, - &focus_handle, - )) - .on_click(cx.listener(|this, _, window, cx| { - this.stage_all(window, cx) - })), - ), - ) - }, - ) - .child( - Button::new("commit", "Commit") + .when( + !button_states.unstage_all || button_states.stage_all, + |this| { + this.child( + Button::new("stage-all", "Stage All") + .width(rems_from_px(80.)) + .disabled(!button_states.stage_all) .tooltip(Tooltip::for_action_title_in( - "Commit", - &Commit, + "Stage All Changes", + &StageAll, &focus_handle, )) - .on_click(cx.listener(|this, _, window, cx| { - this.dispatch_action(&Commit, window, cx); - })), - ), + .on_click( + cx.listener(|this, _, window, cx| this.stage_all(window, cx)), + ), + ) + }, + ) + .child(Divider::vertical()) + .child( + Button::new("commit", "Commit") + .tooltip(Tooltip::for_action_title_in( + "Commit", + &Commit, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&Commit, window, cx); + })), ) - // "Send Review to Agent" button (only shown when there are review comments) .when(review_count > 0, |el| { - el.child(vertical_divider()).child( + el.child(Divider::vertical()).child( render_send_review_to_agent_button(review_count, &focus_handle).on_click( cx.listener(|this, _, window, cx| { this.dispatch_action(&SendReviewToAgent, window, cx) @@ -1985,13 +1983,20 @@ impl Render for BranchDiffToolbar { let show_review_button = !is_multibuffer_empty && is_ai_enabled; - h_group_xl() + h_flex() .my_neg_1() .py_1() - .items_center() + .gap_1p5() .flex_wrap() - .justify_end() - .gap_2() + .justify_between() + .when(!is_multibuffer_empty, |this| { + this.child(DiffStat::new( + "branch-diff-stat", + additions as usize, + deletions as usize, + )) + }) + .child(Divider::vertical().ml_1()) .child( PopoverMenu::new("branch-diff-base-branch-picker") .menu(move |window, cx| { @@ -2013,6 +2018,7 @@ impl Render for BranchDiffToolbar { .ok(); }, ); + Some(branch_picker::select_popover( workspace.clone(), repository.clone(), @@ -2023,23 +2029,14 @@ impl Render for BranchDiffToolbar { )) }) .trigger_with_tooltip( - Button::new("branch-diff-base-branch", base_ref_label) - .color(Color::Muted) - .end_icon( - Icon::new(IconName::ChevronDown) - .size(IconSize::XSmall) - .color(Color::Muted), - ), - Tooltip::text("Select base branch"), + Button::new("branch-diff-base-branch", base_ref_label).end_icon( + Icon::new(IconName::ChevronDown) + .size(IconSize::XSmall) + .color(Color::Muted), + ), + Tooltip::text("Select Base Branch"), ), ) - .when(!is_multibuffer_empty, |this| { - this.child(DiffStat::new( - "branch-diff-stat", - additions as usize, - deletions as usize, - )) - }) .when(show_review_button, |this| { let focus_handle = focus_handle.clone(); this.child(Divider::vertical()).child( @@ -2049,7 +2046,6 @@ impl Render for BranchDiffToolbar { .size(IconSize::Small) .color(Color::Muted), ) - .key_binding(KeyBinding::for_action_in(&ReviewDiff, &focus_handle, cx)) .tooltip(move |_, cx| { Tooltip::with_meta_in( "Review Diff", @@ -2065,7 +2061,7 @@ impl Render for BranchDiffToolbar { ) }) .when(review_count > 0, |this| { - this.child(vertical_divider()).child( + this.child(Divider::vertical()).child( render_send_review_to_agent_button(review_count, &focus_handle).on_click( cx.listener(|this, _, window, cx| { this.dispatch_action(&SendReviewToAgent, window, cx) diff --git a/crates/git_ui/src/solo_diff_view.rs b/crates/git_ui/src/solo_diff_view.rs index 90bde497fa1..9ada614cfcf 100644 --- a/crates/git_ui/src/solo_diff_view.rs +++ b/crates/git_ui/src/solo_diff_view.rs @@ -1,18 +1,19 @@ -use crate::{git_panel::GitStatusEntry, git_status_icon}; +use crate::{git_panel::GitStatusEntry, git_panel_settings::GitPanelSettings, git_status_icon}; use anyhow::{Context as _, Result}; use buffer_diff::DiffHunkSecondaryStatus; use editor::{ - Direction, Editor, EditorEvent, EditorSettings, SplittableEditor, ToggleSplitDiff, + DiffStyleControls, Direction, Editor, EditorEvent, EditorSettings, SplittableEditor, + ToggleSplitDiff, actions::{GoToHunk, GoToPreviousHunk}, + file_status_label_color, }; -use fs::Fs; use git::{ Commit, Restore, StageAndNext, StageFile, ToggleStaged, UnstageAndNext, UnstageFile, repository::RepoPath, status::StageStatus, }; use gpui::{ - Action, AnyElement, App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, - Focusable, IntoElement, Render, Subscription, Task, WeakEntity, Window, + Action, AnyElement, App, AppContext as _, Context, Empty, Entity, EventEmitter, FocusHandle, + Focusable, HighlightStyle, IntoElement, Render, Subscription, Task, WeakEntity, Window, }; use language::{Anchor, Buffer, HighlightedText, OffsetRangeExt as _, Point}; use multi_buffer::{MultiBuffer, PathKey, excerpt_context_lines}; @@ -20,16 +21,13 @@ use project::{ Project, git_store::{Repository, RepositoryId}, }; -use settings::{DiffViewStyle, Settings, SettingsStore, update_settings_file}; +use settings::{Settings, SettingsStore, StatusStyle}; use std::{ any::{Any, TypeId}, ops::Range, sync::Arc, }; -use ui::{ - Color, DiffStat, Divider, Icon, IconButton, IconButtonShape, IconName, Label, LabelCommon as _, - SharedString, Tooltip, prelude::*, vertical_divider, -}; +use ui::{DiffStat, Divider, Tooltip, prelude::*}; use util::paths::{PathExt as _, PathStyle}; use workspace::{ Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, @@ -479,16 +477,31 @@ impl Item for SoloDiffView { } fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> { + let text: SharedString = self + .repo_path + .as_ref() + .display(PathStyle::local()) + .into_owned() + .into(); + + // When the git panel is set to convey status via label color rather + // than an icon, tint the whole path like multibuffer headers do. + let mut highlights = Vec::new(); + if GitPanelSettings::get_global(cx).status_style == StatusStyle::LabelColor + && let Some(status) = self + .repository + .read(cx) + .status_for_path(&self.repo_path) + .map(|entry| entry.status) + { + highlights.push(( + 0..text.len(), + HighlightStyle::color(file_status_label_color(Some(status)).color(cx)), + )); + } + Some(( - vec![HighlightedText { - text: self - .repo_path - .as_ref() - .display(PathStyle::local()) - .into_owned() - .into(), - highlights: Vec::new(), - }], + vec![HighlightedText { text, highlights }], Some( theme_settings::ThemeSettings::get_global(cx) .buffer_font @@ -548,43 +561,6 @@ impl SoloDiffStyleToolbar { self.solo_diff.as_ref()?.upgrade() } - fn set_diff_view_style( - &mut self, - diff_view_style: DiffViewStyle, - window: &mut Window, - cx: &mut Context, - ) { - let Some(solo_diff) = self.solo_diff() else { - return; - }; - let workspace = solo_diff.read(cx).workspace.clone(); - - update_settings_file(::global(cx), cx, move |settings, _| { - settings.editor.diff_view_style = Some(diff_view_style); - }); - - if let Some(workspace) = workspace.upgrade() { - let splittable_editors = { - workspace - .read(cx) - .items(cx) - .filter_map(|item| item.act_as_type(TypeId::of::(), cx)) - .filter_map(|item| item.downcast::().ok()) - .collect::>() - }; - - for editor in splittable_editors { - editor.update(cx, |editor, cx| { - if editor.diff_view_style() != diff_view_style { - editor.toggle_split(&ToggleSplitDiff, window, cx); - } - }); - } - } - - cx.notify(); - } - fn toggle_showing_full_file(&mut self, cx: &mut Context) { if let Some(solo_diff) = self.solo_diff() { solo_diff.update(cx, |solo_diff, cx| { @@ -617,64 +593,49 @@ impl ToolbarItemView for SoloDiffStyleToolbar { impl Render for SoloDiffStyleToolbar { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let Some(solo_diff) = self.solo_diff() else { - return div(); + return Empty.into_any_element(); }; - let (editor_entity, showing_full_file) = { + + let (editor_entity, showing_full_file, status) = { let solo_diff = solo_diff.read(cx); - (solo_diff.editor.clone(), solo_diff.showing_full_file) + ( + solo_diff.editor.clone(), + solo_diff.showing_full_file, + solo_diff + .repository + .read(cx) + .status_for_path(&solo_diff.repo_path) + .map(|entry| entry.status), + ) }; - let editor = editor_entity.read(cx); - let diff_view_style = editor.diff_view_style(); - let is_split_set = diff_view_style == DiffViewStyle::Split; - let split_icon = if is_split_set && !editor.is_split() { - IconName::DiffSplitAuto + + let show_status_icon = + GitPanelSettings::get_global(cx).status_style != StatusStyle::LabelColor; + + let (expand_icon, expand_tooltip) = if showing_full_file { + (IconName::ChevronDownUp, "Show Changes Only") } else { - IconName::DiffSplit + (IconName::ChevronUpDown, "Show Full File") }; h_flex() - .h_8() - .items_center() + .pl_0p5() .gap_1() .child( - IconButton::new( - "solo-diff-toggle-excerpts", - if showing_full_file { - IconName::ChevronDownUp - } else { - IconName::ChevronUpDown - }, - ) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text(if showing_full_file { - "Show Changes Only" - } else { - "Show Full File" - })) - .on_click(cx.listener(|this, _, _, cx| { - this.toggle_showing_full_file(cx); - })), - ) - .child( - IconButton::new("solo-diff-unified", IconName::DiffUnified) + IconButton::new("solo-diff-toggle-excerpts", expand_icon) .icon_size(IconSize::Small) - .toggle_state(diff_view_style == DiffViewStyle::Unified) - .tooltip(Tooltip::text("Unified")) - .on_click(cx.listener(|this, _, window, cx| { - this.set_diff_view_style(DiffViewStyle::Unified, window, cx); + .tooltip(Tooltip::text(expand_tooltip)) + .on_click(cx.listener(|this, _, _, cx| { + this.toggle_showing_full_file(cx); })), ) - .child( - IconButton::new("solo-diff-split", split_icon) - .icon_size(IconSize::Small) - .toggle_state(diff_view_style == DiffViewStyle::Split) - .tooltip(Tooltip::text("Split")) - .on_click(cx.listener(|this, _, window, cx| { - this.set_diff_view_style(DiffViewStyle::Split, window, cx); - })), + .child(DiffStyleControls::new(editor_entity)) + .child(Divider::vertical().mr_1()) + .when_some( + show_status_icon.then_some(status).flatten(), + |this, status| this.child(git_status_icon(status)), ) - .child(vertical_divider()) - .child(div().w_1()) + .into_any_element() } } @@ -745,8 +706,9 @@ struct SoloDiffButtonStates { impl Render for SoloDiffGitToolbar { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let Some(solo_diff) = self.solo_diff() else { - return div(); + return gpui::Empty.into_any_element(); }; + let focus_handle = solo_diff.focus_handle(cx); let solo_diff = solo_diff.read(cx); let button_states = solo_diff.button_states(cx); @@ -754,32 +716,59 @@ impl Render for SoloDiffGitToolbar { .repository .read(cx) .status_for_path(&solo_diff.repo_path); - let status = status_entry.as_ref().map(|entry| entry.status); let diff_stat = status_entry.and_then(|entry| entry.diff_stat); - h_group_xl() + h_flex() .my_neg_1() .py_1() - .items_center() + .gap_1p5() .flex_wrap() .justify_between() - .children(status.map(|status| git_status_icon(status).into_any_element())) .children(diff_stat.map(|stat| { DiffStat::new("solo-diff-stat", stat.added as usize, stat.deleted as usize) - .into_any_element() })) + .child(Divider::vertical().ml_1()) + .child( + h_group_sm() + .child( + IconButton::new("up", IconName::ArrowUp) + .icon_size(IconSize::Small) + .disabled(!button_states.prev_next) + .tooltip(Tooltip::for_action_title_in( + "Go to Previous Hunk", + &GoToPreviousHunk, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&GoToPreviousHunk, window, cx) + })), + ) + .child( + IconButton::new("down", IconName::ArrowDown) + .icon_size(IconSize::Small) + .disabled(!button_states.prev_next) + .tooltip(Tooltip::for_action_title_in( + "Go to Next Hunk", + &GoToHunk, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&GoToHunk, window, cx) + })), + ), + ) .child(Divider::vertical()) .child( h_group_sm() .when(button_states.selection, |el| { el.child( Button::new("stage", "Toggle Staged") + .disabled(!button_states.stage && !button_states.unstage) .tooltip(Tooltip::for_action_title_in( "Toggle Staged", &ToggleStaged, &focus_handle, )) - .disabled(!button_states.stage && !button_states.unstage) .on_click(cx.listener(|this, _, window, cx| { this.dispatch_action(&ToggleStaged, window, cx) })), @@ -788,24 +777,24 @@ impl Render for SoloDiffGitToolbar { .when(!button_states.selection, |el| { el.child( Button::new("stage", "Stage") + .disabled(!button_states.stage) .tooltip(Tooltip::for_action_title_in( - "Stage and go to next hunk", + "Stage and Go to Next Hunk", &StageAndNext, &focus_handle, )) - .disabled(!button_states.stage) .on_click(cx.listener(|this, _, window, cx| { this.dispatch_action(&StageAndNext, window, cx) })), ) .child( Button::new("unstage", "Unstage") + .disabled(!button_states.unstage) .tooltip(Tooltip::for_action_title_in( - "Unstage and go to next hunk", + "Unstage and Go to Next Hunk", &UnstageAndNext, &focus_handle, )) - .disabled(!button_states.unstage) .on_click(cx.listener(|this, _, window, cx| { this.dispatch_action(&UnstageAndNext, window, cx) })), @@ -824,72 +813,40 @@ impl Render for SoloDiffGitToolbar { })), ), ) + .child(Divider::vertical()) + .child(h_group_sm().child(if button_states.stage_file { + Button::new("stage-file", "Stage All") + .width(rems_from_px(80.)) + .disabled(!button_states.stage_file) + .tooltip(Tooltip::for_action_title_in( + "Stage All", + &StageFile, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| this.stage_file(window, cx))) + } else { + Button::new("unstage-file", "Unstage All") + .width(rems_from_px(80.)) + .disabled(!button_states.unstage_file) + .tooltip(Tooltip::for_action_title_in( + "Unstage All", + &UnstageFile, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| this.unstage_file(window, cx))) + })) + .child(Divider::vertical()) .child( - h_group_sm() - .child( - IconButton::new("up", IconName::ArrowUp) - .shape(IconButtonShape::Square) - .tooltip(Tooltip::for_action_title_in( - "Go to previous hunk", - &GoToPreviousHunk, - &focus_handle, - )) - .disabled(!button_states.prev_next) - .on_click(cx.listener(|this, _, window, cx| { - this.dispatch_action(&GoToPreviousHunk, window, cx) - })), - ) - .child( - IconButton::new("down", IconName::ArrowDown) - .shape(IconButtonShape::Square) - .tooltip(Tooltip::for_action_title_in( - "Go to next hunk", - &GoToHunk, - &focus_handle, - )) - .disabled(!button_states.prev_next) - .on_click(cx.listener(|this, _, window, cx| { - this.dispatch_action(&GoToHunk, window, cx) - })), - ), - ) - .child(vertical_divider()) - .child( - h_group_sm() - .child(if button_states.stage_file { - Button::new("stage-file", "Stage File") - .tooltip(Tooltip::for_action_title_in( - "Stage file", - &StageFile, - &focus_handle, - )) - .disabled(!button_states.stage_file) - .on_click( - cx.listener(|this, _, window, cx| this.stage_file(window, cx)), - ) - } else { - Button::new("unstage-file", "Unstage File") - .tooltip(Tooltip::for_action_title_in( - "Unstage file", - &UnstageFile, - &focus_handle, - )) - .disabled(!button_states.unstage_file) - .on_click( - cx.listener(|this, _, window, cx| this.unstage_file(window, cx)), - ) - }) - .child( - Button::new("commit", "Commit") - .tooltip(Tooltip::for_action_title_in( - "Commit", - &Commit, - &focus_handle, - )) - .on_click(cx.listener(|this, _, window, cx| { - this.dispatch_action(&Commit, window, cx); - })), - ), + Button::new("commit", "Commit") + .tooltip(Tooltip::for_action_title_in( + "Commit", + &Commit, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&Commit, window, cx); + })), ) + .into_any_element() } } diff --git a/crates/picker/src/footer.rs b/crates/picker/src/footer.rs index fc90c5f2530..ff0b11caa94 100644 --- a/crates/picker/src/footer.rs +++ b/crates/picker/src/footer.rs @@ -145,7 +145,7 @@ impl Picker { ), ) .when(preview_visible, |this| { - this.child(Divider::vertical().h_4().mx_1()) + this.child(Divider::vertical().mx_1()) .child( IconButton::new("picker-preview-right", IconName::DiffSplit) .icon_size(IconSize::Small) diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 5e20f2e7ecf..d0c0a5385ff 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -14,15 +14,15 @@ use crate::{ use any_vec::AnyVec; use collections::HashMap; use editor::{ - Editor, EditorSettings, MultiBufferOffset, SplittableEditor, ToggleSplitDiff, + DiffStyleControls, Editor, EditorSettings, MultiBufferOffset, SplittableEditor, actions::{Backtab, FoldAll, Tab, ToggleFoldAll, UnfoldAll}, scroll::Autoscroll, }; use futures::channel::oneshot; use gpui::{ - Action as _, App, ClickEvent, Context, Entity, EventEmitter, Focusable, - InteractiveElement as _, IntoElement, KeyContext, ParentElement as _, Render, ScrollHandle, - Styled, Subscription, Task, TaskExt, WeakEntity, Window, div, + App, ClickEvent, Context, Entity, EventEmitter, Focusable, InteractiveElement as _, + IntoElement, KeyContext, ParentElement as _, Render, ScrollHandle, Styled, Subscription, Task, + TaskExt, WeakEntity, Window, div, }; use language::{Language, LanguageRegistry}; use project::{ @@ -30,17 +30,11 @@ use project::{ search_history::{SearchHistory, SearchHistoryCursor}, }; -use fs::Fs; -use settings::{DiffViewStyle, SeedQuerySetting, Settings, update_settings_file}; +use settings::{SeedQuerySetting, Settings}; use std::{any::TypeId, sync::Arc}; -use zed_actions::{ - OpenSettingsAt, outline::ToggleOutline, workspace::CopyPath, workspace::CopyRelativePath, -}; +use zed_actions::{outline::ToggleOutline, workspace::CopyPath, workspace::CopyRelativePath}; -use ui::{ - BASE_REM_SIZE_IN_PX, IconButtonShape, PlatformStyle, TextSize, Tooltip, prelude::*, - render_modifiers, utils::SearchInputWidth, -}; +use ui::{BASE_REM_SIZE_IN_PX, IconButtonShape, Tooltip, prelude::*, utils::SearchInputWidth}; use util::{ResultExt, paths::PathMatcher}; use workspace::{ ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, @@ -109,127 +103,11 @@ impl Render for BufferSearchBar { let focus_handle = self.focus_handle(cx); let has_splittable_editor = self.splittable_editor.is_some(); - let split_buttons = if has_splittable_editor { - self.splittable_editor - .as_ref() - .and_then(|weak| weak.upgrade()) - .map(|splittable_editor| { - let editor_ref = splittable_editor.read(cx); - let diff_view_style = editor_ref.diff_view_style(); - - let is_split_set = diff_view_style == DiffViewStyle::Split; - let is_split_active = editor_ref.is_split(); - let min_columns = - EditorSettings::get_global(cx).minimum_split_diff_width as u32; - - let split_icon = if is_split_set && !is_split_active { - IconName::DiffSplitAuto - } else { - IconName::DiffSplit - }; - - h_flex() - .gap_1() - .child( - IconButton::new("diff-unified", IconName::DiffUnified) - .icon_size(IconSize::Small) - .toggle_state(diff_view_style == DiffViewStyle::Unified) - .tooltip(Tooltip::text("Unified")) - .on_click({ - let splittable_editor = splittable_editor.downgrade(); - move |_, window, cx| { - update_settings_file( - ::global(cx), - cx, - |settings, _| { - settings.editor.diff_view_style = - Some(DiffViewStyle::Unified); - }, - ); - if diff_view_style == DiffViewStyle::Split { - splittable_editor - .update(cx, |editor, cx| { - editor.toggle_split( - &ToggleSplitDiff, - window, - cx, - ); - }) - .ok(); - } - } - }), - ) - .child( - IconButton::new("diff-split", split_icon) - .toggle_state(diff_view_style == DiffViewStyle::Split) - .icon_size(IconSize::Small) - .tooltip(Tooltip::element(move |_, cx| { - let message = if is_split_set && !is_split_active { - format!("Split when wider than {} columns", min_columns) - .into() - } else { - SharedString::from("Split") - }; - - v_flex() - .child(message) - .child( - h_flex() - .gap_0p5() - .text_ui_sm(cx) - .text_color(Color::Muted.color(cx)) - .children(render_modifiers( - &gpui::Modifiers::secondary_key(), - PlatformStyle::platform(), - None, - Some(TextSize::Small.rems(cx).into()), - false, - )) - .child("click to change min width"), - ) - .into_any() - })) - .on_click({ - let splittable_editor = splittable_editor.downgrade(); - move |_, window, cx| { - if window.modifiers().secondary() { - window.dispatch_action( - OpenSettingsAt { - path: "minimum_split_diff_width".to_string(), - target: None, - } - .boxed_clone(), - cx, - ); - } else { - update_settings_file( - ::global(cx), - cx, - |settings, _| { - settings.editor.diff_view_style = - Some(DiffViewStyle::Split); - }, - ); - if diff_view_style == DiffViewStyle::Unified { - splittable_editor - .update(cx, |editor, cx| { - editor.toggle_split( - &ToggleSplitDiff, - window, - cx, - ); - }) - .ok(); - } - } - } - }), - ) - }) - } else { - None - }; + let split_buttons = self + .splittable_editor + .as_ref() + .and_then(|weak| weak.upgrade()) + .map(DiffStyleControls::new); let collapse_expand_button = if self.needs_expand_collapse_option(cx) { let query_editor_focus = self.query_editor.focus_handle(cx); diff --git a/crates/ui/src/components/button/split_button.rs b/crates/ui/src/components/button/split_button.rs index 01cfc9a38b5..315bbc95f12 100644 --- a/crates/ui/src/components/button/split_button.rs +++ b/crates/ui/src/components/button/split_button.rs @@ -1,10 +1,11 @@ use gpui::{ - AnyElement, App, BoxShadow, IntoElement, ParentElement, RenderOnce, Styled, Window, div, hsla, - prelude::FluentBuilder, px, relative, + AnyElement, App, BoxShadow, ParentElement, RenderOnce, Styled, Window, div, hsla, prelude::*, + px, }; + use theme::ActiveTheme; -use crate::{ElevationIndex, prelude::*}; +use crate::{Divider, ElevationIndex, prelude::*}; use super::ButtonLike; @@ -80,12 +81,13 @@ impl RenderOnce for SplitButton { SplitButtonKind::ButtonLike(button) => button.into_any_element(), SplitButtonKind::IconButton(icon) => icon.into_any_element(), })) - .child( - div() - .h(relative(0.8)) - .w_px() - .bg(cx.theme().colors().border.opacity(0.5)), - ) + .child(Divider::vertical().map(|s| { + if self.style == SplitButtonStyle::Filled { + s.h_full().color(crate::DividerColor::Border) + } else { + s.h_4() + } + })) .child(self.right) .when(self.style == SplitButtonStyle::Filled, |this| { this.bg(ElevationIndex::Surface.on_elevation_bg(cx)) diff --git a/crates/ui/src/components/divider.rs b/crates/ui/src/components/divider.rs index 74c697a76ac..7e343867f14 100644 --- a/crates/ui/src/components/divider.rs +++ b/crates/ui/src/components/divider.rs @@ -2,26 +2,6 @@ use gpui::{Hsla, IntoElement, PathBuilder, Refineable as _, StyleRefinement, can use crate::prelude::*; -pub fn divider() -> Divider { - Divider { - line_style: DividerStyle::Solid, - direction: DividerDirection::Horizontal, - color: DividerColor::default(), - style: StyleRefinement::default(), - inset: false, - } -} - -pub fn vertical_divider() -> Divider { - Divider { - line_style: DividerStyle::Solid, - direction: DividerDirection::Vertical, - color: DividerColor::default(), - style: StyleRefinement::default(), - inset: false, - } -} - #[derive(Clone, Copy, PartialEq)] enum DividerStyle { Solid, @@ -166,7 +146,7 @@ impl RenderOnce for Divider { DividerDirection::Vertical => div() .min_w_0() .w_px() - .h_full() + .h_4() .when(self.inset, |this| this.my_1p5()), }; From 5d6c88cdb71168d31c35a1057702f2fa26d5494e Mon Sep 17 00:00:00 2001 From: Salman Farooq Date: Mon, 6 Jul 2026 20:56:59 +0500 Subject: [PATCH 089/197] workspace: Use ellipsis character in "New" tooltip (#60467) Explanation by @danilo-leal at https://github.com/zed-industries/zed/pull/60181#issuecomment-4866118310 ## Testing Ran with `cargo run` and tooltip appears correctly. ## Release Notes: - N/A --- crates/workspace/src/pane.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 55736c0824b..dc3dcd68b8d 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -4213,7 +4213,7 @@ fn default_render_tab_bar_buttons( PopoverMenu::new("pane-tab-bar-popover-menu") .trigger_with_tooltip( IconButton::new("plus", IconName::Plus).icon_size(IconSize::Small), - Tooltip::text("New..."), + Tooltip::text("New…"), ) .anchor(Anchor::TopRight) .with_handle(pane.new_item_context_menu_handle.clone()) From 283d5054ec5663795e8f9d744260c6c57b85f9e3 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Mon, 6 Jul 2026 18:02:54 +0200 Subject: [PATCH 090/197] ci: Set and enforce more default permissions (#60222) Release Notes: - N/A --- .github/workflows/after_release.yml | 2 + .github/workflows/autofix_pr.yml | 5 + .github/workflows/bump_collab_staging.yml | 5 + .github/workflows/bump_patch_version.yml | 4 + .github/workflows/bump_zed_version.yml | 9 ++ .github/workflows/cherry_pick.yml | 2 + .../comment_on_potential_duplicate_issues.yml | 2 + .../community_close_stale_issues.yml | 5 + ...ommunity_update_all_top_ranking_issues.yml | 6 + ...unity_update_weekly_top_ranking_issues.yml | 6 + .github/workflows/compliance_check.yml | 2 + .github/workflows/congrats.yml | 3 + .github/workflows/danger.yml | 2 + .github/workflows/deploy_collab.yml | 2 + .github/workflows/deploy_docs.yml | 2 + .github/workflows/deploy_nightly_docs.yml | 1 + .github/workflows/docs_suggestions.yml | 4 + .github/workflows/extension_auto_bump.yml | 2 + .github/workflows/extension_bump.yml | 2 + .github/workflows/extension_tests.yml | 2 + .../workflows/extension_workflow_rollout.yml | 2 + .../workflows/good_first_issue_notifier.yml | 3 + .github/workflows/hotfix-review-monitor.yml | 3 +- .github/workflows/nix_build.yml | 2 + .github/workflows/publish_extension_cli.yml | 2 + .github/workflows/release.yml | 4 + .github/workflows/release_nightly.yml | 2 + .github/workflows/run_bundling.yml | 2 + .github/workflows/run_tests.yml | 2 + ...ck_notify_community_automation_failure.yml | 3 + .../slack_notify_first_responders.yml | 3 + .../workflows/slack_notify_label_created.yml | 3 + .github/workflows/stale-pr-reminder.yml | 3 +- .../workflows/update_duplicate_magnets.yml | 6 + extensions/workflows/run_tests.yml | 2 + extensions/workflows/shared/bump_version.yml | 1 + tooling/xtask/src/tasks/workflow_checks.rs | 147 +++++++++++++----- .../workflow_checks/check_permissions.rs | 74 +++++++++ .../workflow_checks/check_run_patterns.rs | 71 ++------- tooling/xtask/src/tasks/workflows.rs | 4 +- .../src/tasks/workflows/after_release.rs | 5 +- .../xtask/src/tasks/workflows/autofix_pr.rs | 10 +- .../src/tasks/workflows/bump_patch_version.rs | 4 +- .../src/tasks/workflows/bump_zed_version.rs | 10 +- .../xtask/src/tasks/workflows/cherry_pick.rs | 6 +- .../src/tasks/workflows/compliance_check.rs | 3 +- tooling/xtask/src/tasks/workflows/danger.rs | 3 +- .../src/tasks/workflows/deploy_collab.rs | 4 +- .../xtask/src/tasks/workflows/deploy_docs.rs | 6 +- .../tasks/workflows/extension_auto_bump.rs | 3 +- .../src/tasks/workflows/extension_bump.rs | 8 +- .../src/tasks/workflows/extension_tests.rs | 3 +- .../workflows/extension_workflow_rollout.rs | 5 +- .../workflows/extensions/bump_version.rs | 1 + .../tasks/workflows/extensions/run_tests.rs | 1 + .../xtask/src/tasks/workflows/nix_build.rs | 3 +- .../tasks/workflows/publish_extension_cli.rs | 5 +- tooling/xtask/src/tasks/workflows/release.rs | 10 +- .../src/tasks/workflows/release_nightly.rs | 5 +- .../xtask/src/tasks/workflows/run_bundling.rs | 6 +- .../xtask/src/tasks/workflows/run_tests.rs | 5 +- tooling/xtask/src/tasks/workflows/steps.rs | 11 ++ 62 files changed, 388 insertions(+), 136 deletions(-) create mode 100644 tooling/xtask/src/tasks/workflow_checks/check_permissions.rs diff --git a/.github/workflows/after_release.yml b/.github/workflows/after_release.yml index 9fb93ee27d5..5a833447e9e 100644 --- a/.github/workflows/after_release.yml +++ b/.github/workflows/after_release.yml @@ -22,6 +22,8 @@ on: description: body type: string default: '' +permissions: + contents: read jobs: rebuild_releases_page: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') diff --git a/.github/workflows/autofix_pr.yml b/.github/workflows/autofix_pr.yml index 9918f6be0fc..1c7b72da68e 100644 --- a/.github/workflows/autofix_pr.yml +++ b/.github/workflows/autofix_pr.yml @@ -13,9 +13,14 @@ on: description: run_clippy type: boolean default: 'true' +permissions: + contents: read jobs: run_autofix: runs-on: namespace-profile-16x32-ubuntu-2204 + permissions: + contents: read + pull-requests: read env: CC: clang CXX: clang++ diff --git a/.github/workflows/bump_collab_staging.yml b/.github/workflows/bump_collab_staging.yml index 4f9724439f3..be39f41b6e5 100644 --- a/.github/workflows/bump_collab_staging.yml +++ b/.github/workflows/bump_collab_staging.yml @@ -5,10 +5,15 @@ on: # Fire every day at 16:00 UTC (At the start of the US workday) - cron: "0 16 * * *" +permissions: + contents: read + jobs: update-collab-staging-tag: if: github.repository_owner == 'zed-industries' runs-on: namespace-profile-2x4-ubuntu-2404 + permissions: + contents: write steps: - name: Checkout repository uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 diff --git a/.github/workflows/bump_patch_version.yml b/.github/workflows/bump_patch_version.yml index 3618d7230f7..7be909c907c 100644 --- a/.github/workflows/bump_patch_version.yml +++ b/.github/workflows/bump_patch_version.yml @@ -8,10 +8,14 @@ on: description: Branch name to run on required: true type: string +permissions: + contents: read jobs: run_bump_patch_version: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') runs-on: namespace-profile-16x32-ubuntu-2204 + permissions: + contents: write steps: - id: generate-token name: steps::authenticate_as_zippy diff --git a/.github/workflows/bump_zed_version.yml b/.github/workflows/bump_zed_version.yml index f8fae3de7af..07b17aca32a 100644 --- a/.github/workflows/bump_zed_version.yml +++ b/.github/workflows/bump_zed_version.yml @@ -8,6 +8,8 @@ on: description: 'Which channels to bump: all, main, preview, or stable' type: string default: all +permissions: + contents: read jobs: resolve_versions: if: github.repository_owner == 'zed-industries' @@ -86,6 +88,9 @@ jobs: - resolve_versions if: inputs.target == 'all' || inputs.target == 'main' runs-on: namespace-profile-16x32-ubuntu-2204 + permissions: + contents: write + pull-requests: write steps: - id: generate-token name: steps::authenticate_as_zippy @@ -127,6 +132,8 @@ jobs: - resolve_versions if: inputs.target == 'all' || inputs.target == 'preview' runs-on: namespace-profile-16x32-ubuntu-2204 + permissions: + contents: write steps: - id: generate-token name: steps::authenticate_as_zippy @@ -180,6 +187,8 @@ jobs: - resolve_versions if: inputs.target == 'all' || inputs.target == 'stable' runs-on: namespace-profile-16x32-ubuntu-2204 + permissions: + contents: write steps: - id: generate-token name: steps::authenticate_as_zippy diff --git a/.github/workflows/cherry_pick.yml b/.github/workflows/cherry_pick.yml index 82dc9fb545d..0e18eb2d16d 100644 --- a/.github/workflows/cherry_pick.yml +++ b/.github/workflows/cherry_pick.yml @@ -21,6 +21,8 @@ on: description: pr_number required: true type: string +permissions: + contents: read jobs: run_cherry_pick: runs-on: namespace-profile-2x4-ubuntu-2404 diff --git a/.github/workflows/comment_on_potential_duplicate_issues.yml b/.github/workflows/comment_on_potential_duplicate_issues.yml index 0d7ce3aad3c..c6e79bff848 100644 --- a/.github/workflows/comment_on_potential_duplicate_issues.yml +++ b/.github/workflows/comment_on_potential_duplicate_issues.yml @@ -14,6 +14,8 @@ concurrency: group: potential-duplicate-check-${{ github.event.issue.number || inputs.issue_number }} cancel-in-progress: true +permissions: {} + jobs: identify-duplicates: # For manual testing, allow running on any branch; for automatic runs, only on main repo diff --git a/.github/workflows/community_close_stale_issues.yml b/.github/workflows/community_close_stale_issues.yml index be1d8e66d04..f309dd589b7 100644 --- a/.github/workflows/community_close_stale_issues.yml +++ b/.github/workflows/community_close_stale_issues.yml @@ -13,10 +13,15 @@ on: type: number default: 1000 +permissions: + contents: read + jobs: stale: if: github.repository_owner == 'zed-industries' runs-on: namespace-profile-2x4-ubuntu-2404 + permissions: + issues: write steps: - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10 with: diff --git a/.github/workflows/community_update_all_top_ranking_issues.yml b/.github/workflows/community_update_all_top_ranking_issues.yml index b8003a69b24..55ba214ad30 100644 --- a/.github/workflows/community_update_all_top_ranking_issues.yml +++ b/.github/workflows/community_update_all_top_ranking_issues.yml @@ -5,10 +5,16 @@ on: - cron: "0 */12 * * *" workflow_dispatch: +permissions: + contents: read + jobs: update_top_ranking_issues: runs-on: namespace-profile-2x4-ubuntu-2404 if: github.repository == 'zed-industries/zed' + permissions: + contents: read + issues: write steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up uv diff --git a/.github/workflows/community_update_weekly_top_ranking_issues.yml b/.github/workflows/community_update_weekly_top_ranking_issues.yml index 90d1934ffcb..4dbe5e8700a 100644 --- a/.github/workflows/community_update_weekly_top_ranking_issues.yml +++ b/.github/workflows/community_update_weekly_top_ranking_issues.yml @@ -5,10 +5,16 @@ on: - cron: "0 15 * * *" workflow_dispatch: +permissions: + contents: read + jobs: update_top_ranking_issues: runs-on: namespace-profile-2x4-ubuntu-2404 if: github.repository == 'zed-industries/zed' + permissions: + contents: read + issues: write steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up uv diff --git a/.github/workflows/compliance_check.yml b/.github/workflows/compliance_check.yml index 2cf27fea8b0..a701371d76e 100644 --- a/.github/workflows/compliance_check.yml +++ b/.github/workflows/compliance_check.yml @@ -7,6 +7,8 @@ on: schedule: - cron: 30 17 * * 2 workflow_dispatch: {} +permissions: + contents: read jobs: scheduled_compliance_check: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') diff --git a/.github/workflows/congrats.yml b/.github/workflows/congrats.yml index 4866b3c33bc..f6d04265975 100644 --- a/.github/workflows/congrats.yml +++ b/.github/workflows/congrats.yml @@ -4,6 +4,9 @@ on: push: branches: [main] +permissions: + contents: read + jobs: check-author: if: ${{ github.repository_owner == 'zed-industries' }} diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 4e94c613a6b..9bef39ade45 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -11,6 +11,8 @@ on: - edited branches: - main +permissions: + contents: read jobs: danger: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') diff --git a/.github/workflows/deploy_collab.yml b/.github/workflows/deploy_collab.yml index 043c18ebc7c..b40c55afb14 100644 --- a/.github/workflows/deploy_collab.yml +++ b/.github/workflows/deploy_collab.yml @@ -7,6 +7,8 @@ on: push: tags: - collab-production +permissions: + contents: read jobs: style: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index 6c492135ea6..ace3c0729e7 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -35,6 +35,8 @@ on: description: Git ref to checkout and deploy. Defaults to event SHA when omitted. type: string default: '' +permissions: + contents: read jobs: deploy_docs: if: github.repository_owner == 'zed-industries' diff --git a/.github/workflows/deploy_nightly_docs.yml b/.github/workflows/deploy_nightly_docs.yml index acd904841bb..12d10f10b26 100644 --- a/.github/workflows/deploy_nightly_docs.yml +++ b/.github/workflows/deploy_nightly_docs.yml @@ -5,6 +5,7 @@ on: push: branches: - main +permissions: {} jobs: deploy_docs: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') diff --git a/.github/workflows/docs_suggestions.yml b/.github/workflows/docs_suggestions.yml index c3d04d5780b..f0405af5e94 100644 --- a/.github/workflows/docs_suggestions.yml +++ b/.github/workflows/docs_suggestions.yml @@ -42,6 +42,10 @@ on: - immediate default: batch +# Both jobs below declare their own `permissions:` blocks, so no job uses this +# top-level default. Least privilege therefore grants nothing here. +permissions: {} + env: DROID_MODEL: claude-sonnet-4-5-20250929 SUGGESTIONS_BRANCH: docs/suggestions-pending diff --git a/.github/workflows/extension_auto_bump.yml b/.github/workflows/extension_auto_bump.yml index e48ccdb082a..df9fcc98693 100644 --- a/.github/workflows/extension_auto_bump.yml +++ b/.github/workflows/extension_auto_bump.yml @@ -10,6 +10,8 @@ on: - '!extensions/test-extension/**' - '!extensions/workflows/**' - '!extensions/*.md' +permissions: + contents: read jobs: detect_changed_extensions: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml index 6e68db7af0f..494a97b0977 100644 --- a/.github/workflows/extension_bump.yml +++ b/.github/workflows/extension_bump.yml @@ -28,6 +28,8 @@ on: app-secret: description: The app secret for the corresponding app ID required: true +permissions: + contents: read jobs: check_version_changed: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') diff --git a/.github/workflows/extension_tests.yml b/.github/workflows/extension_tests.yml index 23efa368d17..9a6f223f38f 100644 --- a/.github/workflows/extension_tests.yml +++ b/.github/workflows/extension_tests.yml @@ -15,6 +15,8 @@ on: description: working-directory type: string default: . +permissions: + contents: read jobs: orchestrate: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') diff --git a/.github/workflows/extension_workflow_rollout.yml b/.github/workflows/extension_workflow_rollout.yml index 62a9c75d518..d1a47870e6f 100644 --- a/.github/workflows/extension_workflow_rollout.yml +++ b/.github/workflows/extension_workflow_rollout.yml @@ -14,6 +14,8 @@ on: description: Description for the changes to be expected with this rollout type: string default: '' +permissions: + contents: read jobs: fetch_extension_repos: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && (github.ref == 'refs/tags/extension-workflows' || github.ref == 'refs/heads/main') diff --git a/.github/workflows/good_first_issue_notifier.yml b/.github/workflows/good_first_issue_notifier.yml index fc1b49424dc..9e86118dd0a 100644 --- a/.github/workflows/good_first_issue_notifier.yml +++ b/.github/workflows/good_first_issue_notifier.yml @@ -4,6 +4,9 @@ on: issues: types: [labeled] +permissions: + contents: read + jobs: handle-good-first-issue: if: github.event.label.name == '.contrib/good first issue' && github.repository_owner == 'zed-industries' diff --git a/.github/workflows/hotfix-review-monitor.yml b/.github/workflows/hotfix-review-monitor.yml index 760cd9806c9..c6e4bfe6e53 100644 --- a/.github/workflows/hotfix-review-monitor.yml +++ b/.github/workflows/hotfix-review-monitor.yml @@ -21,12 +21,13 @@ on: permissions: contents: read - pull-requests: read jobs: check-hotfix-reviews: if: github.repository_owner == 'zed-industries' runs-on: ubuntu-latest + permissions: + pull-requests: read timeout-minutes: 5 env: REPO: ${{ github.repository }} diff --git a/.github/workflows/nix_build.yml b/.github/workflows/nix_build.yml index f658634c06c..3ddd6bdd5c6 100644 --- a/.github/workflows/nix_build.yml +++ b/.github/workflows/nix_build.yml @@ -9,6 +9,8 @@ on: types: - labeled - synchronize +permissions: + contents: read jobs: build_nix_linux_x86_64: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && ((github.event.action == 'labeled' && (github.event.label.name == 'run-nix' || github.event.label.name == 'run-bundling')) || (github.event.action == 'synchronize' && (contains(github.event.pull_request.labels.*.name, 'run-nix') || contains(github.event.pull_request.labels.*.name, 'run-bundling')))) diff --git a/.github/workflows/publish_extension_cli.yml b/.github/workflows/publish_extension_cli.yml index b2d8e96fcea..bee3c7c21f5 100644 --- a/.github/workflows/publish_extension_cli.yml +++ b/.github/workflows/publish_extension_cli.yml @@ -11,6 +11,8 @@ on: description: Describe why the extension CLI is being bumped and/or what changes are included. required: true type: string +permissions: + contents: read jobs: publish_job: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && github.ref == 'refs/heads/main' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8115259c0b7..ec65ab2c872 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,8 @@ on: push: tags: - v* +permissions: + contents: read jobs: run_tests_mac: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') @@ -723,6 +725,8 @@ jobs: - bundle_windows_aarch64 - bundle_windows_x86_64 runs-on: namespace-profile-4x8-ubuntu-2204 + permissions: + contents: write steps: - name: release::download_workflow_artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index e1c72767d77..cf9f4728aa4 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -8,6 +8,8 @@ on: schedule: - cron: 0 */4 * * * workflow_dispatch: {} +permissions: + contents: read jobs: check_nightly_tag: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') diff --git a/.github/workflows/run_bundling.yml b/.github/workflows/run_bundling.yml index b05015677c2..4299bac4013 100644 --- a/.github/workflows/run_bundling.yml +++ b/.github/workflows/run_bundling.yml @@ -9,6 +9,8 @@ on: types: - labeled - synchronize +permissions: + contents: read jobs: bundle_linux_aarch64: if: |- diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 3b613f255cc..055279340ce 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -14,6 +14,8 @@ on: branches: - main - v[0-9]+.[0-9]+.x +permissions: + contents: read jobs: orchestrate: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') diff --git a/.github/workflows/slack_notify_community_automation_failure.yml b/.github/workflows/slack_notify_community_automation_failure.yml index ae58a67868d..d02b3b23c23 100644 --- a/.github/workflows/slack_notify_community_automation_failure.yml +++ b/.github/workflows/slack_notify_community_automation_failure.yml @@ -18,6 +18,9 @@ on: - "Guild New PR Notification" types: [completed] +permissions: + contents: read + jobs: notify-slack: if: >- diff --git a/.github/workflows/slack_notify_first_responders.yml b/.github/workflows/slack_notify_first_responders.yml index 3dd9ffeabae..91507ebeefd 100644 --- a/.github/workflows/slack_notify_first_responders.yml +++ b/.github/workflows/slack_notify_first_responders.yml @@ -4,6 +4,9 @@ on: issues: types: [labeled] +permissions: + contents: read + env: PRIORITY_LABELS: '["priority:P0", "priority:P1"]' REPRODUCIBLE_LABEL: 'state:reproducible' diff --git a/.github/workflows/slack_notify_label_created.yml b/.github/workflows/slack_notify_label_created.yml index e791cbc7ea4..f30650dc220 100644 --- a/.github/workflows/slack_notify_label_created.yml +++ b/.github/workflows/slack_notify_label_created.yml @@ -4,6 +4,9 @@ on: label: types: [created] +permissions: + contents: read + jobs: notify-slack: if: >- diff --git a/.github/workflows/stale-pr-reminder.yml b/.github/workflows/stale-pr-reminder.yml index 1c3c0aec623..a35430857ec 100644 --- a/.github/workflows/stale-pr-reminder.yml +++ b/.github/workflows/stale-pr-reminder.yml @@ -20,13 +20,14 @@ on: permissions: contents: read - pull-requests: read jobs: check-stale-prs: if: github.repository_owner == 'zed-industries' runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + pull-requests: read env: REPO: ${{ github.repository }} # Only surface PRs created on or after this date. Update this if the diff --git a/.github/workflows/update_duplicate_magnets.yml b/.github/workflows/update_duplicate_magnets.yml index d14f4aa9245..1d073ba0484 100644 --- a/.github/workflows/update_duplicate_magnets.yml +++ b/.github/workflows/update_duplicate_magnets.yml @@ -5,10 +5,16 @@ on: - cron: "0 6 * * 1,4" # Mondays and Thursdays at 6 AM UTC workflow_dispatch: +permissions: + contents: read + jobs: update-duplicate-magnets: runs-on: ubuntu-latest if: github.repository == 'zed-industries/zed' + permissions: + contents: read + issues: write steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 diff --git a/extensions/workflows/run_tests.yml b/extensions/workflows/run_tests.yml index aa748dc5784..f7e2287700f 100644 --- a/extensions/workflows/run_tests.yml +++ b/extensions/workflows/run_tests.yml @@ -8,6 +8,8 @@ on: push: branches: - main +permissions: + contents: read jobs: call_extension_tests: permissions: diff --git a/extensions/workflows/shared/bump_version.yml b/extensions/workflows/shared/bump_version.yml index dbe92a43a5a..cf4aa1068fa 100644 --- a/extensions/workflows/shared/bump_version.yml +++ b/extensions/workflows/shared/bump_version.yml @@ -11,6 +11,7 @@ on: paths-ignore: - .github/** workflow_dispatch: {} +permissions: {} jobs: determine_bump_type: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') diff --git a/tooling/xtask/src/tasks/workflow_checks.rs b/tooling/xtask/src/tasks/workflow_checks.rs index d6be0299327..2b57c8509cd 100644 --- a/tooling/xtask/src/tasks/workflow_checks.rs +++ b/tooling/xtask/src/tasks/workflow_checks.rs @@ -1,20 +1,22 @@ +mod check_permissions; mod check_run_patterns; -use std::{fs, path::PathBuf}; +use std::{ + fs, + path::{Path, PathBuf}, +}; -use annotate_snippets::Renderer; +use annotate_snippets::{Group, Renderer}; use anyhow::{Result, anyhow}; use clap::Parser; use itertools::{Either, Itertools}; use serde_yaml::Value; use strum::IntoEnumIterator; -use crate::tasks::{ - workflow_checks::check_run_patterns::{ - RunValidationError, WorkflowFile, WorkflowValidationError, - }, - workflows::WorkflowType, -}; +use crate::tasks::workflows::WorkflowType; + +use check_permissions::PermissionsError; +use check_run_patterns::RunValidationError; pub use check_run_patterns::validate_run_command; @@ -36,14 +38,14 @@ pub fn validate(_: WorkflowValidationArgs) -> Result<()> { parsing_errors.into_iter().join("\n") )) } else if !file_errors.is_empty() { - let errors: Vec<_> = file_errors + let groups: Vec<_> = file_errors .iter() - .map(|error| error.annotation_group()) + .flat_map(|error| error.annotation_groups()) .collect(); let renderer = Renderer::styled().decor_style(annotate_snippets::renderer::DecorStyle::Ascii); - println!("{}", renderer.render(errors.as_slice())); + println!("{}", renderer.render(groups.as_slice())); Err(anyhow!("Workflow checks failed!")) } else { @@ -51,6 +53,61 @@ pub fn validate(_: WorkflowValidationArgs) -> Result<()> { } } +struct WorkflowFile { + raw_content: String, + parsed_content: Value, +} + +impl WorkflowFile { + fn load(workflow_file_path: &Path) -> Result { + fs::read_to_string(workflow_file_path) + .map_err(|_| { + anyhow!( + "Could not read workflow file at {}", + workflow_file_path.display() + ) + }) + .and_then(|file_content| { + serde_yaml::from_str(&file_content) + .map(|parsed_content| Self { + raw_content: file_content, + parsed_content, + }) + .map_err(|e| anyhow!("Failed to parse workflow file: {e:?}")) + }) + } +} + +/// A single kind of validation failure found within a workflow file. +enum ValidationError { + RunInjection(RunValidationError), + Permissions(PermissionsError), +} + +impl ValidationError { + fn annotation_group<'a>(&self, file_path: &Path, raw_content: &'a str) -> Group<'a> { + match self { + ValidationError::RunInjection(error) => error.annotation_group(file_path, raw_content), + ValidationError::Permissions(error) => error.annotation_group(file_path, raw_content), + } + } +} + +struct WorkflowValidationError { + file_path: PathBuf, + contents: WorkflowFile, + errors: Vec, +} + +impl WorkflowValidationError { + fn annotation_groups(&self) -> Vec> { + self.errors + .iter() + .map(|error| error.annotation_group(&self.file_path, &self.contents.raw_content)) + .collect() + } +} + enum WorkflowError { ParseError(anyhow::Error), ValidationError(Box), @@ -73,41 +130,47 @@ fn get_all_workflow_files() -> impl Iterator { } fn check_workflow(workflow_file_path: PathBuf) -> Result<(), WorkflowError> { - fn collect_errors( - iter: impl Iterator>>, - ) -> Result<(), Vec> { - Some(iter.flat_map(Result::err).flatten().collect::>()) - .filter(|errors| !errors.is_empty()) - .map_or(Ok(()), Err) - } - - fn check_recursive(key: &Value, value: &Value) -> Result<(), Vec> { - match value { - Value::Mapping(mapping) => collect_errors( - mapping - .into_iter() - .map(|(key, value)| check_recursive(key, value)), - ), - Value::Sequence(sequence) => collect_errors( - sequence - .into_iter() - .map(|value| check_recursive(key, value)), - ), - Value::String(string) => check_string(key, string).map_err(|error| vec![error]), - Value::Null | Value::Bool(_) | Value::Number(_) | Value::Tagged(_) => Ok(()), - } - } - let file_content = WorkflowFile::load(&workflow_file_path).map_err(WorkflowError::ParseError)?; - check_recursive(&Value::Null, &file_content.parsed_content).map_err(|errors| { - WorkflowError::ValidationError(Box::new(WorkflowValidationError::new( - errors, - file_content, - workflow_file_path, + let mut errors = Vec::new(); + + if let Err(error) = check_permissions::validate_permissions(&file_content.parsed_content) { + errors.push(ValidationError::Permissions(error)); + } + + errors.extend( + collect_run_injection_errors(&Value::Null, &file_content.parsed_content) + .into_iter() + .map(ValidationError::RunInjection), + ); + + if errors.is_empty() { + Ok(()) + } else { + Err(WorkflowError::ValidationError(Box::new( + WorkflowValidationError { + file_path: workflow_file_path, + contents: file_content, + errors, + }, ))) - }) + } +} + +fn collect_run_injection_errors(key: &Value, value: &Value) -> Vec { + match value { + Value::Mapping(mapping) => mapping + .iter() + .flat_map(|(key, value)| collect_run_injection_errors(key, value)) + .collect(), + Value::Sequence(sequence) => sequence + .iter() + .flat_map(|value| collect_run_injection_errors(key, value)) + .collect(), + Value::String(string) => check_string(key, string).err().into_iter().collect(), + Value::Null | Value::Bool(_) | Value::Number(_) | Value::Tagged(_) => Vec::new(), + } } fn check_string(key: &Value, value: &str) -> Result<(), RunValidationError> { diff --git a/tooling/xtask/src/tasks/workflow_checks/check_permissions.rs b/tooling/xtask/src/tasks/workflow_checks/check_permissions.rs new file mode 100644 index 00000000000..9fdbc003dde --- /dev/null +++ b/tooling/xtask/src/tasks/workflow_checks/check_permissions.rs @@ -0,0 +1,74 @@ +use annotate_snippets::{AnnotationKind, Group, Level, Snippet}; +use serde_yaml::Value; +use std::{ops::Range, path::Path}; + +pub enum PermissionsError { + /// No top-level `permissions:` key. + Missing, + /// Top-level default grants more than `contents: read`. + ExcessiveDefault, +} + +/// Validates a workflow's top-level (default) `permissions:`. +/// +/// Every workflow must declare one, and it may grant at most `contents: read`; +/// jobs that need more must request it at the job level. +pub fn validate_permissions(workflow: &Value) -> Result<(), PermissionsError> { + let Some(permissions) = workflow.get("permissions") else { + return Err(PermissionsError::Missing); + }; + + let is_minimal = match permissions { + Value::Mapping(mapping) => mapping.iter().all(|(scope, level)| { + let level = level.as_str(); + level == Some("none") || (scope.as_str() == Some("contents") && level == Some("read")) + }), + // String forms such as `read-all`/`write-all` always exceed the allowance. + _ => false, + }; + + if is_minimal { + Ok(()) + } else { + Err(PermissionsError::ExcessiveDefault) + } +} + +impl PermissionsError { + pub fn annotation_group<'a>(&self, file_path: &Path, raw_content: &'a str) -> Group<'a> { + let (title, span, label) = match self { + PermissionsError::Missing => ( + "Workflow is missing a top-level `permissions:` key", + first_line_span(raw_content, "name:"), + "Add a top-level `permissions:` key so this workflow defaults to the least privilege it needs", + ), + PermissionsError::ExcessiveDefault => ( + "Top-level workflow permissions must grant at most `contents: read`", + first_line_span(raw_content, "permissions:"), + "Lower the default to `contents: read` (or `{}`) and move elevated permissions to the jobs that need them", + ), + }; + + Level::ERROR.primary_title(title).element( + Snippet::source(raw_content) + .path(file_path.display().to_string()) + .annotations(std::iter::once( + AnnotationKind::Primary.span(span).label(label), + )), + ) + } +} + +/// Span of the first column-0 line starting with `prefix`, else empty. +fn first_line_span(raw_content: &str, prefix: &str) -> Range { + let mut offset = 0; + for line in raw_content.lines() { + let start = offset; + offset += line.len() + 1; + if line.starts_with(prefix) { + return start..start + line.len(); + } + } + + Default::default() +} diff --git a/tooling/xtask/src/tasks/workflow_checks/check_run_patterns.rs b/tooling/xtask/src/tasks/workflow_checks/check_run_patterns.rs index 50c435d0333..2ea2aea517c 100644 --- a/tooling/xtask/src/tasks/workflow_checks/check_run_patterns.rs +++ b/tooling/xtask/src/tasks/workflow_checks/check_run_patterns.rs @@ -1,72 +1,25 @@ use annotate_snippets::{AnnotationKind, Group, Level, Snippet}; -use anyhow::{Result, anyhow}; use regex::Regex; -use serde_yaml::Value; -use std::{ - collections::HashMap, - fs, - ops::Range, - path::{Path, PathBuf}, - sync::LazyLock, -}; +use std::{collections::HashMap, ops::Range, path::Path, sync::LazyLock}; static GITHUB_INPUT_PATTERN: LazyLock = LazyLock::new(|| { Regex::new(r#"\$\{\{[[:blank:]]*([[:alnum:]]|[[:punct:]])+?[[:blank:]]*\}\}"#) .expect("Should compile") }); -pub struct WorkflowFile { - raw_content: String, - pub parsed_content: Value, +pub struct RunValidationError { + found_injection_patterns: Vec<(String, Range)>, } -impl WorkflowFile { - pub fn load(workflow_file_path: &Path) -> Result { - fs::read_to_string(workflow_file_path) - .map_err(|_| { - anyhow!( - "Could not read workflow file at {}", - workflow_file_path.display() - ) - }) - .and_then(|file_content| { - serde_yaml::from_str(&file_content) - .map(|parsed_content| Self { - raw_content: file_content, - parsed_content, - }) - .map_err(|e| anyhow!("Failed to parse workflow file: {e:?}")) - }) - } -} - -pub struct WorkflowValidationError { - file_path: PathBuf, - contents: WorkflowFile, - errors: Vec, -} - -impl WorkflowValidationError { - pub fn new( - errors: Vec, - contents: WorkflowFile, - file_path: PathBuf, - ) -> Self { - Self { - file_path, - contents, - errors, - } - } - - pub fn annotation_group<'a>(&'a self) -> Group<'a> { - let raw_content = &self.contents.raw_content; +impl RunValidationError { + /// Renders the GitHub input injection patterns found in a single `run:` + /// command as one diagnostic group. + pub fn annotation_group<'a>(&self, file_path: &Path, raw_content: &'a str) -> Group<'a> { let mut identical_lines = HashMap::new(); let ranges = self - .errors + .found_injection_patterns .iter() - .flat_map(|error| error.found_injection_patterns.iter()) .map(|(line, pattern_range)| { let initial_offset = identical_lines .get(&(line.as_str(), pattern_range.start)) @@ -89,8 +42,8 @@ impl WorkflowValidationError { Level::ERROR .primary_title("Found GitHub input injection in run command") .element( - Snippet::source(&self.contents.raw_content) - .path(self.file_path.display().to_string()) + Snippet::source(raw_content) + .path(file_path.display().to_string()) .annotations(ranges.map(|range| { AnnotationKind::Primary .span(range) @@ -100,10 +53,6 @@ impl WorkflowValidationError { } } -pub struct RunValidationError { - found_injection_patterns: Vec<(String, Range)>, -} - pub fn validate_run_command(command: &str) -> Result<(), RunValidationError> { let patterns: Vec<_> = command .lines() diff --git a/tooling/xtask/src/tasks/workflows.rs b/tooling/xtask/src/tasks/workflows.rs index 14516c18f22..86a2a952205 100644 --- a/tooling/xtask/src/tasks/workflows.rs +++ b/tooling/xtask/src/tasks/workflows.rs @@ -136,8 +136,8 @@ impl WorkflowFile { .as_ref() .expect("Workflow must have a name at this point"); let filename = format!( - "{}.yml", - workflow_name.rsplit("::").next().unwrap_or(workflow_name) + "{workflow_name}.yml", + workflow_name = workflow_name.rsplit("::").next().unwrap_or(workflow_name) ); let workflow_path = workflow_folder.join(filename); diff --git a/tooling/xtask/src/tasks/workflows/after_release.rs b/tooling/xtask/src/tasks/workflows/after_release.rs index bb363736c34..960d5f24552 100644 --- a/tooling/xtask/src/tasks/workflows/after_release.rs +++ b/tooling/xtask/src/tasks/workflows/after_release.rs @@ -4,7 +4,9 @@ use crate::tasks::workflows::{ deploy_docs::deploy_docs_workflow_call, release::{self, notify_on_failure}, runners, - steps::{CommonJobConditions, NamedJob, checkout_repo, dependant_job, named}, + steps::{ + CommonJobConditions, CommonPermissionSets, NamedJob, checkout_repo, dependant_job, named, + }, vars::{self, StepOutput, WorkflowInput}, }; @@ -39,6 +41,7 @@ pub fn after_release() -> Workflow { }; named::workflow() + .with_minimal_permissions() .add_env(("TAG_NAME", TAG_NAME_ENV)) .add_env(("IS_PRERELEASE", IS_PRERELEASE_ENV)) .on(Event::default() diff --git a/tooling/xtask/src/tasks/workflows/autofix_pr.rs b/tooling/xtask/src/tasks/workflows/autofix_pr.rs index e576f43f91d..60c66459118 100644 --- a/tooling/xtask/src/tasks/workflows/autofix_pr.rs +++ b/tooling/xtask/src/tasks/workflows/autofix_pr.rs @@ -3,8 +3,8 @@ use gh_workflow::*; use crate::tasks::workflows::{ runners, steps::{ - self, DownloadArtifactStep, FluentBuilder, IfNoFilesFound, NamedJob, RepositoryTarget, - TokenPermissions, UploadArtifactStep, ZippyGitIdentity, named, use_clang, + self, CommonPermissionSets, DownloadArtifactStep, FluentBuilder, IfNoFilesFound, NamedJob, + RepositoryTarget, TokenPermissions, UploadArtifactStep, ZippyGitIdentity, named, use_clang, }, vars::{self, StepOutput, WorkflowInput}, }; @@ -15,6 +15,7 @@ pub fn autofix_pr() -> Workflow { let run_autofix = run_autofix(&pr_number, &run_clippy); let commit_changes = commit_changes(&pr_number, &run_autofix); named::workflow() + .with_minimal_permissions() .run_name(format!("autofix PR #{pr_number}")) .on(Event::default().workflow_dispatch( WorkflowDispatch::default() @@ -91,6 +92,11 @@ fn run_autofix(pr_number: &WorkflowInput, run_clippy: &WorkflowInput) -> NamedJo named::job(use_clang( Job::default() .runs_on(runners::LINUX_DEFAULT) + .permissions( + Permissions::default() + .contents(Level::Read) + .pull_requests(Level::Read), + ) .outputs([( "has_changes".to_owned(), "${{ steps.create-patch.outputs.has_changes }}".to_owned(), diff --git a/tooling/xtask/src/tasks/workflows/bump_patch_version.rs b/tooling/xtask/src/tasks/workflows/bump_patch_version.rs index fcff7418a25..6b9abab426c 100644 --- a/tooling/xtask/src/tasks/workflows/bump_patch_version.rs +++ b/tooling/xtask/src/tasks/workflows/bump_patch_version.rs @@ -2,7 +2,7 @@ use gh_workflow::*; use crate::tasks::workflows::{ runners, - steps::{self, CheckoutStep, CommonJobConditions, named}, + steps::{self, CheckoutStep, CommonJobConditions, CommonPermissionSets, named}, vars::{StepOutput, WorkflowInput}, }; @@ -10,6 +10,7 @@ pub fn bump_patch_version() -> Workflow { let branch = WorkflowInput::string("branch", None).description("Branch name to run on"); let bump_patch_version_job = run_bump_patch_version(&branch); named::workflow() + .with_minimal_permissions() .on(Event::default() .workflow_dispatch(WorkflowDispatch::default().add_input(branch.name, branch.input()))) .concurrency( @@ -80,6 +81,7 @@ fn run_bump_patch_version(branch: &WorkflowInput) -> steps::NamedJob { named::job( Job::default() .with_repository_owner_guard() + .permissions(Permissions::default().contents(Level::Write)) .runs_on(runners::LINUX_DEFAULT) .add_step(authenticate) .add_step(checkout_branch(branch, &token)) diff --git a/tooling/xtask/src/tasks/workflows/bump_zed_version.rs b/tooling/xtask/src/tasks/workflows/bump_zed_version.rs index 5501412f1be..ccbbd19ee0f 100644 --- a/tooling/xtask/src/tasks/workflows/bump_zed_version.rs +++ b/tooling/xtask/src/tasks/workflows/bump_zed_version.rs @@ -2,7 +2,7 @@ use gh_workflow::*; use crate::tasks::workflows::{ runners, - steps::{self, named}, + steps::{self, CommonPermissionSets, named}, vars::{self, StepOutput, WorkflowInput}, }; @@ -17,6 +17,7 @@ pub fn bump_zed_version() -> Workflow { let stable_job = promote_to_stable(&target, &versions_job, &outputs); named::workflow() + .with_minimal_permissions() .on(Event::default() .workflow_dispatch(WorkflowDispatch::default().add_input(target.name, target.input()))) .add_job(versions_job.name, versions_job.job) @@ -142,6 +143,11 @@ fn bump_main( target.expr(), target.expr(), ))) + .permissions( + Permissions::default() + .contents(Level::Write) + .pull_requests(Level::Write), + ) .needs(vec![versions_job.name.clone()]) .runs_on(runners::LINUX_DEFAULT) .add_step(authenticate) @@ -193,6 +199,7 @@ fn create_preview_branch( target.expr(), target.expr(), ))) + .permissions(Permissions::default().contents(Level::Write)) .needs(vec![versions_job.name.clone()]) .runs_on(runners::LINUX_DEFAULT) .add_step(authenticate) @@ -250,6 +257,7 @@ fn promote_to_stable( target.expr(), target.expr(), ))) + .permissions(Permissions::default().contents(Level::Write)) .needs(vec![versions_job.name.clone()]) .runs_on(runners::LINUX_DEFAULT) .add_step(authenticate) diff --git a/tooling/xtask/src/tasks/workflows/cherry_pick.rs b/tooling/xtask/src/tasks/workflows/cherry_pick.rs index 15d2808dbf6..cc9e8ca64b4 100644 --- a/tooling/xtask/src/tasks/workflows/cherry_pick.rs +++ b/tooling/xtask/src/tasks/workflows/cherry_pick.rs @@ -2,7 +2,10 @@ use gh_workflow::*; use crate::tasks::workflows::{ runners, - steps::{self, NamedJob, RepositoryTarget, TokenPermissions, ZippyGitIdentity, named}, + steps::{ + self, CommonPermissionSets, NamedJob, RepositoryTarget, TokenPermissions, ZippyGitIdentity, + named, + }, vars::{StepOutput, WorkflowInput}, }; @@ -13,6 +16,7 @@ pub fn cherry_pick() -> Workflow { let pr_number = WorkflowInput::string("pr_number", None); let cherry_pick = run_cherry_pick(&branch, &commit, &channel); named::workflow() + .with_minimal_permissions() .run_name(format!("cherry_pick to {channel} #{pr_number}")) .on(Event::default().workflow_dispatch( WorkflowDispatch::default() diff --git a/tooling/xtask/src/tasks/workflows/compliance_check.rs b/tooling/xtask/src/tasks/workflows/compliance_check.rs index 5918bc47677..00f6f1fbe90 100644 --- a/tooling/xtask/src/tasks/workflows/compliance_check.rs +++ b/tooling/xtask/src/tasks/workflows/compliance_check.rs @@ -3,7 +3,7 @@ use gh_workflow::{Event, Job, Schedule, Workflow, WorkflowDispatch}; use crate::tasks::workflows::{ release::{ComplianceContext, add_compliance_steps}, runners, - steps::{self, CommonJobConditions, named}, + steps::{self, CommonJobConditions, CommonPermissionSets, named}, vars::StepOutput, }; @@ -11,6 +11,7 @@ pub fn compliance_check() -> Workflow { let check = scheduled_compliance_check(); named::workflow() + .with_minimal_permissions() .on(Event::default() .schedule([Schedule::new("30 17 * * 2")]) .workflow_dispatch(WorkflowDispatch::default())) diff --git a/tooling/xtask/src/tasks/workflows/danger.rs b/tooling/xtask/src/tasks/workflows/danger.rs index c8e18f6966a..564abf3b30b 100644 --- a/tooling/xtask/src/tasks/workflows/danger.rs +++ b/tooling/xtask/src/tasks/workflows/danger.rs @@ -1,6 +1,6 @@ use gh_workflow::*; -use crate::tasks::workflows::steps::{CommonJobConditions, NamedJob, named}; +use crate::tasks::workflows::steps::{CommonJobConditions, CommonPermissionSets, NamedJob, named}; use super::{runners, steps}; @@ -9,6 +9,7 @@ pub fn danger() -> Workflow { let danger = danger_job(); named::workflow() + .with_minimal_permissions() .on(Event::default() .pull_request(PullRequest::default().add_branch("main").types([ PullRequestType::Opened, diff --git a/tooling/xtask/src/tasks/workflows/deploy_collab.rs b/tooling/xtask/src/tasks/workflows/deploy_collab.rs index e372a27d237..5302bc2f3ff 100644 --- a/tooling/xtask/src/tasks/workflows/deploy_collab.rs +++ b/tooling/xtask/src/tasks/workflows/deploy_collab.rs @@ -3,7 +3,8 @@ use indoc::indoc; use crate::tasks::workflows::runners::{self, Platform}; use crate::tasks::workflows::steps::{ - self, CommonJobConditions, FluentBuilder as _, NamedJob, dependant_job, named, use_clang, + self, CommonJobConditions, CommonPermissionSets, FluentBuilder as _, NamedJob, dependant_job, + named, use_clang, }; use crate::tasks::workflows::vars; @@ -14,6 +15,7 @@ pub(crate) fn deploy_collab() -> Workflow { let deploy = deploy(&[&publish]); named::workflow() + .with_minimal_permissions() .on(Event::default().push(Push::default().add_tag("collab-production"))) .add_env(("DOCKER_BUILDKIT", "1")) .add_job(style.name, style.job) diff --git a/tooling/xtask/src/tasks/workflows/deploy_docs.rs b/tooling/xtask/src/tasks/workflows/deploy_docs.rs index f60d1097d6e..7871022f770 100644 --- a/tooling/xtask/src/tasks/workflows/deploy_docs.rs +++ b/tooling/xtask/src/tasks/workflows/deploy_docs.rs @@ -6,8 +6,8 @@ use gh_workflow::{ use crate::tasks::workflows::{ runners, steps::{ - self, CommonJobConditions, FluentBuilder as _, NamedJob, UploadArtifactStep, named, - release_job, + self, CommonJobConditions, CommonPermissionSets, FluentBuilder as _, NamedJob, + UploadArtifactStep, named, release_job, }, vars::{self, StepOutput, WorkflowInput}, }; @@ -314,6 +314,7 @@ pub(crate) fn deploy_docs() -> Workflow { let deploy_docs = deploy_docs_job(&channel, &checkout_ref); named::workflow() + .with_minimal_permissions() .add_event( Event::default().workflow_dispatch( WorkflowDispatch::default() @@ -366,6 +367,7 @@ pub(crate) fn deploy_nightly_docs() -> Workflow { named::workflow() .name("deploy_nightly_docs") + .permissions(Permissions::default()) .add_event(Event::default().push(Push::default().add_branch("main"))) .add_job(deploy_docs.name, deploy_docs.job) } diff --git a/tooling/xtask/src/tasks/workflows/extension_auto_bump.rs b/tooling/xtask/src/tasks/workflows/extension_auto_bump.rs index e69c783299a..9958a7638d6 100644 --- a/tooling/xtask/src/tasks/workflows/extension_auto_bump.rs +++ b/tooling/xtask/src/tasks/workflows/extension_auto_bump.rs @@ -8,7 +8,7 @@ use crate::tasks::workflows::{ extensions::WithAppSecrets, run_tests::DETECT_CHANGED_EXTENSIONS_SCRIPT, runners, - steps::{self, CommonJobConditions, NamedJob, named}, + steps::{self, CommonJobConditions, CommonPermissionSets, NamedJob, named}, vars::{StepOutput, one_workflow_per_non_main_branch}, }; @@ -20,6 +20,7 @@ pub(crate) fn extension_auto_bump() -> Workflow { let bump = bump_extension_versions(&detect); named::workflow() + .with_minimal_permissions() .add_event( Event::default().push( Push::default() diff --git a/tooling/xtask/src/tasks/workflows/extension_bump.rs b/tooling/xtask/src/tasks/workflows/extension_bump.rs index e4efebfe67d..9f6f6462b6c 100644 --- a/tooling/xtask/src/tasks/workflows/extension_bump.rs +++ b/tooling/xtask/src/tasks/workflows/extension_bump.rs @@ -5,9 +5,10 @@ use crate::tasks::workflows::{ extension_tests::{self}, runners, steps::{ - self, BASH_SHELL, CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, GitHubScriptStep, - GitRef, NamedJob, RefSha, RepositoryTarget, cache_rust_dependencies_namespace, - checkout_repo, create_ref, dependant_job, generate_token, named, + self, BASH_SHELL, CommonJobConditions, CommonPermissionSets, + DEFAULT_REPOSITORY_OWNER_GUARD, GitHubScriptStep, GitRef, NamedJob, RefSha, + RepositoryTarget, cache_rust_dependencies_namespace, checkout_repo, create_ref, + dependant_job, generate_token, named, }, vars::{ JobOutput, StepOutput, WorkflowInput, WorkflowSecret, @@ -58,6 +59,7 @@ pub(crate) fn extension_bump() -> Workflow { ); named::workflow() + .with_minimal_permissions() .add_event( Event::default().workflow_call( WorkflowCall::default() diff --git a/tooling/xtask/src/tasks/workflows/extension_tests.rs b/tooling/xtask/src/tasks/workflows/extension_tests.rs index b184db64e3f..8626dd5e1c0 100644 --- a/tooling/xtask/src/tasks/workflows/extension_tests.rs +++ b/tooling/xtask/src/tasks/workflows/extension_tests.rs @@ -8,7 +8,7 @@ use crate::tasks::workflows::{ }, runners, steps::{ - self, BASH_SHELL, CommonJobConditions, FluentBuilder, NamedJob, + self, BASH_SHELL, CommonJobConditions, CommonPermissionSets, FluentBuilder, NamedJob, cache_rust_dependencies_namespace, named, }, vars::{PathCondition, StepOutput, WorkflowInput, one_workflow_per_non_main_branch_and_token}, @@ -41,6 +41,7 @@ pub(crate) fn extension_tests() -> Workflow { let working_directory = WorkflowInput::string("working-directory", Some(".".to_owned())); named::workflow() + .with_minimal_permissions() .add_event( Event::default().workflow_call( WorkflowCall::default() diff --git a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs index 9eb9d456a62..19e2c5e28db 100644 --- a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs +++ b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs @@ -8,8 +8,8 @@ use serde_json::json; use crate::tasks::workflows::steps::GitRef; use crate::tasks::workflows::steps::RefSha; use crate::tasks::workflows::steps::{ - CheckoutStep, DownloadArtifactStep, IfNoFilesFound, ResultEncoding, TokenPermissions, - UploadArtifactStep, cache_rust_dependencies_namespace, + CheckoutStep, CommonPermissionSets, DownloadArtifactStep, IfNoFilesFound, ResultEncoding, + TokenPermissions, UploadArtifactStep, cache_rust_dependencies_namespace, }; use crate::tasks::workflows::vars::JobOutput; use crate::tasks::workflows::{ @@ -42,6 +42,7 @@ pub(crate) fn extension_workflow_rollout() -> Workflow { let create_tag = create_rollout_tag(&rollout_workflows, &filter_repos_input); named::workflow() + .with_minimal_permissions() .on(Event::default().workflow_dispatch( WorkflowDispatch::default() .add_input(filter_repos_input.name, filter_repos_input.input()) diff --git a/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs b/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs index 4dc2560e2be..077a0e4cc20 100644 --- a/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs +++ b/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs @@ -27,6 +27,7 @@ pub(crate) fn bump_version(args: &GenerateWorkflowArgs) -> Workflow { ) .pull_request(PullRequest::default().add_type(PullRequestType::Labeled)) .workflow_dispatch(WorkflowDispatch::default())) + .permissions(Permissions::default()) .concurrency(one_workflow_per_non_main_branch_and_token("labels")) .add_job(determine_bump_type.name, determine_bump_type.job) .add_job(call_bump_version.name, call_bump_version.job) diff --git a/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs b/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs index ae8000c15ca..3bcd87c433e 100644 --- a/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs +++ b/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs @@ -9,6 +9,7 @@ use crate::tasks::workflows::{ pub(crate) fn run_tests(args: &GenerateWorkflowArgs) -> Workflow { let call_extension_tests = call_extension_tests(args.sha.as_ref()); named::workflow() + .permissions(Permissions::default().contents(Level::Read)) .on(Event::default() .pull_request(PullRequest::default().add_branch("**")) .push(Push::default().add_branch("main"))) diff --git a/tooling/xtask/src/tasks/workflows/nix_build.rs b/tooling/xtask/src/tasks/workflows/nix_build.rs index cc04ec5b855..2a4521ecdc4 100644 --- a/tooling/xtask/src/tasks/workflows/nix_build.rs +++ b/tooling/xtask/src/tasks/workflows/nix_build.rs @@ -1,6 +1,6 @@ use crate::tasks::workflows::{ runners::{Arch, Platform}, - steps::{CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob}, + steps::{CommonJobConditions, CommonPermissionSets, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob}, }; use super::{runners, steps, steps::named, vars}; @@ -13,6 +13,7 @@ use gh_workflow::*; pub fn nix_build() -> Workflow { let [nix_linux_x86_64, nix_mac_aarch64] = nix_pr_jobs(&["run-nix", "run-bundling"]); named::workflow() + .with_minimal_permissions() .on(Event::default().pull_request( PullRequest::default().types([PullRequestType::Labeled, PullRequestType::Synchronize]), )) diff --git a/tooling/xtask/src/tasks/workflows/publish_extension_cli.rs b/tooling/xtask/src/tasks/workflows/publish_extension_cli.rs index cbe656e734e..d4843cbaf4a 100644 --- a/tooling/xtask/src/tasks/workflows/publish_extension_cli.rs +++ b/tooling/xtask/src/tasks/workflows/publish_extension_cli.rs @@ -4,8 +4,8 @@ use indoc::{formatdoc, indoc}; use crate::tasks::workflows::{ runners, steps::{ - self, DEFAULT_REPOSITORY_OWNER_GUARD, GitRef, NamedJob, RefSha, RepositoryTarget, - TokenPermissions, generate_token, named, + self, CommonPermissionSets, DEFAULT_REPOSITORY_OWNER_GUARD, GitRef, NamedJob, RefSha, + RepositoryTarget, TokenPermissions, generate_token, named, }, vars::{self, StepOutput, WorkflowInput}, }; @@ -22,6 +22,7 @@ pub fn publish_extension_cli() -> Workflow { let update_sha_in_extensions = update_sha_in_extensions(&publish, &message); named::workflow() + .with_minimal_permissions() .on(Event::default().workflow_dispatch( WorkflowDispatch::default().add_input(message.name, message.input()), )) diff --git a/tooling/xtask/src/tasks/workflows/release.rs b/tooling/xtask/src/tasks/workflows/release.rs index f04a9f7ca8a..fc3060e375a 100644 --- a/tooling/xtask/src/tasks/workflows/release.rs +++ b/tooling/xtask/src/tasks/workflows/release.rs @@ -1,4 +1,6 @@ -use gh_workflow::{Event, Expression, Level, Push, Run, Step, Use, Workflow, ctx::Context}; +use gh_workflow::{ + Event, Expression, Level, Permissions, Push, Run, Step, Use, Workflow, ctx::Context, +}; use indoc::formatdoc; use crate::tasks::workflows::{ @@ -6,8 +8,8 @@ use crate::tasks::workflows::{ run_tests, runners::{self, Arch, Platform}, steps::{ - self, DownloadArtifactStep, FluentBuilder, NamedJob, TokenPermissions, dependant_job, - named, release_job, + self, CommonPermissionSets, DownloadArtifactStep, FluentBuilder, NamedJob, + TokenPermissions, dependant_job, named, release_job, }, vars::{self, JobOutput, StepOutput, assets}, }; @@ -101,6 +103,7 @@ pub(crate) fn release() -> Workflow { named::workflow() .on(Event::default().push(Push::default().tags(vec!["v*".to_string()]))) .concurrency(vars::one_workflow_per_non_main_branch()) + .with_minimal_permissions() .add_env(("CARGO_TERM_COLOR", "always")) .add_env(("RUST_BACKTRACE", "1")) .add_job(macos_tests.name, macos_tests.job) @@ -466,6 +469,7 @@ fn upload_release_assets(deps: &[&NamedJob], bundle: &ReleaseBundleJobs) -> Name named::job( dependant_job(&deps) .runs_on(runners::LINUX_MEDIUM) + .permissions(Permissions::default().contents(Level::Write)) .add_step(download_workflow_artifacts()) .add_step(steps::script("ls -lR ./artifacts")) .add_step(prep_release_artifacts()) diff --git a/tooling/xtask/src/tasks/workflows/release_nightly.rs b/tooling/xtask/src/tasks/workflows/release_nightly.rs index a92188ee0d7..4c18ba21368 100644 --- a/tooling/xtask/src/tasks/workflows/release_nightly.rs +++ b/tooling/xtask/src/tasks/workflows/release_nightly.rs @@ -8,8 +8,8 @@ use crate::tasks::workflows::{ run_tests::run_platform_tests_no_filter, runners::{Arch, Platform, ReleaseChannel}, steps::{ - CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, FluentBuilder, GitRef, NamedJob, - RefSha, RepositoryTarget, TokenPermissions, + CommonJobConditions, CommonPermissionSets, DEFAULT_REPOSITORY_OWNER_GUARD, FluentBuilder, + GitRef, NamedJob, RefSha, RepositoryTarget, TokenPermissions, }, }; @@ -47,6 +47,7 @@ pub fn release_nightly() -> Workflow { let notify_on_failure = notify_on_failure(&bundle.jobs()); named::workflow() + .with_minimal_permissions() .on(Event::default() // Fire 6 times a day .schedule([Schedule::new("0 */4 * * *")]) diff --git a/tooling/xtask/src/tasks/workflows/run_bundling.rs b/tooling/xtask/src/tasks/workflows/run_bundling.rs index e9ed32c610c..cb916e849ed 100644 --- a/tooling/xtask/src/tasks/workflows/run_bundling.rs +++ b/tooling/xtask/src/tasks/workflows/run_bundling.rs @@ -3,7 +3,10 @@ use std::path::Path; use crate::tasks::workflows::{ release::ReleaseBundleJobs, runners::{Arch, Platform, ReleaseChannel}, - steps::{FluentBuilder, IfNoFilesFound, NamedJob, UploadArtifactStep, dependant_job, named}, + steps::{ + CommonPermissionSets, FluentBuilder, IfNoFilesFound, NamedJob, UploadArtifactStep, + dependant_job, named, + }, vars::{self, assets, bundle_envs}, }; @@ -23,6 +26,7 @@ pub fn run_bundling() -> Workflow { windows_x86_64: bundle_windows(Arch::X86_64, None, &[]), }; named::workflow() + .with_minimal_permissions() .on(Event::default().pull_request( PullRequest::default().types([PullRequestType::Labeled, PullRequestType::Synchronize]), )) diff --git a/tooling/xtask/src/tasks/workflows/run_tests.rs b/tooling/xtask/src/tasks/workflows/run_tests.rs index dd422fa32e9..c745d0e3852 100644 --- a/tooling/xtask/src/tasks/workflows/run_tests.rs +++ b/tooling/xtask/src/tasks/workflows/run_tests.rs @@ -8,8 +8,8 @@ use serde_json::json; use crate::tasks::workflows::{ steps::{ - CommonJobConditions, cache_rust_dependencies_namespace, repository_owner_guard_expression, - use_clang, + CommonJobConditions, CommonPermissionSets, cache_rust_dependencies_namespace, + repository_owner_guard_expression, use_clang, }, vars::{self, PathCondition}, }; @@ -105,6 +105,7 @@ pub(crate) fn run_tests() -> Workflow { ); // could be more specific here? named::workflow() + .with_minimal_permissions() .add_event( Event::default() .push( diff --git a/tooling/xtask/src/tasks/workflows/steps.rs b/tooling/xtask/src/tasks/workflows/steps.rs index 7351f451a94..5fcb0114421 100644 --- a/tooling/xtask/src/tasks/workflows/steps.rs +++ b/tooling/xtask/src/tasks/workflows/steps.rs @@ -376,6 +376,16 @@ impl CommonJobConditions for Job { } } +pub trait CommonPermissionSets: Sized { + fn with_minimal_permissions(self) -> Self; +} + +impl CommonPermissionSets for Workflow { + fn with_minimal_permissions(self) -> Self { + self.permissions(Permissions::default().contents(Level::Read)) + } +} + pub(crate) fn release_job(deps: &[&NamedJob]) -> Job { dependant_job(deps) .with_repository_owner_guard() @@ -503,6 +513,7 @@ pub mod named { .collect::>() .join("::"), ) + .permissions(Permissions::default()) .defaults(Defaults::default().run(RunDefaults::default().shell(BASH_SHELL))) } From eeff97950f7ccfd5b2f73b48f7267bd0df5e4bfb Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 6 Jul 2026 18:10:48 +0200 Subject: [PATCH 091/197] Add license to tooling/lints crate (#60468) Seeing `script/check-license` fail because we forgot to add a license in #58496 > Error: tooling/lints does not contain a LICENSE-GPL or LICENSE-APACHE symlink Release Notes: - N/A --- .github/workflows/run_tests.yml | 2 +- tooling/lints/LICENSE-APACHE | 1 + tooling/xtask/src/tasks/workflows/run_tests.rs | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) create mode 120000 tooling/lints/LICENSE-APACHE diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 055279340ce..1b8f416baab 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -75,7 +75,7 @@ jobs: # Map directory names to package names FILE_CHANGED_PKGS="" for dir in $CHANGED_DIRS; do - pkg=$(echo "$DIR_TO_PKG" | grep "^${dir}=" | cut -d= -f2 | head -1) + pkg=$(echo "$DIR_TO_PKG" | grep "^${dir}=" | cut -d= -f2 | head -1 || true) if [ -n "$pkg" ]; then FILE_CHANGED_PKGS=$(printf '%s\n%s' "$FILE_CHANGED_PKGS" "$pkg") else diff --git a/tooling/lints/LICENSE-APACHE b/tooling/lints/LICENSE-APACHE new file mode 120000 index 00000000000..1cd601d0a3a --- /dev/null +++ b/tooling/lints/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/tooling/xtask/src/tasks/workflows/run_tests.rs b/tooling/xtask/src/tasks/workflows/run_tests.rs index c745d0e3852..84dc6692392 100644 --- a/tooling/xtask/src/tasks/workflows/run_tests.rs +++ b/tooling/xtask/src/tasks/workflows/run_tests.rs @@ -221,7 +221,7 @@ fn orchestrate_impl(rules: &[&PathCondition], target: OrchestrateTarget) -> Name # Map directory names to package names FILE_CHANGED_PKGS="" for dir in $CHANGED_DIRS; do - pkg=$(echo "$DIR_TO_PKG" | grep "^${dir}=" | cut -d= -f2 | head -1) + pkg=$(echo "$DIR_TO_PKG" | grep "^${dir}=" | cut -d= -f2 | head -1 || true) if [ -n "$pkg" ]; then FILE_CHANGED_PKGS=$(printf '%s\n%s' "$FILE_CHANGED_PKGS" "$pkg") else From 48c03b2f7f2a9ca8e3ee0fd6e1c4eba097d9f516 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Mon, 6 Jul 2026 22:47:57 +0300 Subject: [PATCH 092/197] Better "Restart to Update" button dismissals (#60448) Follow-up of https://github.com/zed-industries/zed/pull/59994 deals with restart to update button overly appearing. The PR mentioned fixed the redownload issue, but the title bar code re-surfaced the button on every update check as `is_updated` in this semantics means a new Nightly update is ready to be installed: https://github.com/zed-industries/zed/blob/159246f0083787124160620118ac35df5f0f3754/crates/auto_update/src/auto_update.rs#L171-L173 and the old code used this as "can show the button again" reason after each recheck. Instead, track dismissed update state better both in the title bar and the global auto updater, so no new version checks can trigger this and no new Zed windows get this button again (as it is now). Release Notes: - Improved "Restart to Update" button dismissals --- crates/auto_update/src/auto_update.rs | 14 ++++++++ crates/title_bar/src/update_version.rs | 46 +++++++++++++++----------- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index e4c837618d9..67643ee6345 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -180,6 +180,7 @@ pub struct AutoUpdater { pending_poll: Option>>, quit_subscription: Option, update_check_type: UpdateCheckType, + dismissed_status: Option, } #[derive(Deserialize, Serialize, Clone, Debug)] @@ -426,6 +427,7 @@ impl AutoUpdater { pending_poll: None, quit_subscription, update_check_type: UpdateCheckType::Automatic, + dismissed_status: None, } } @@ -458,6 +460,9 @@ impl AutoUpdater { } pub fn poll(&mut self, check_type: UpdateCheckType, cx: &mut Context) { + if check_type.is_manual() { + self.dismissed_status = None; + } if self.pending_poll.is_some() { if self.update_check_type == UpdateCheckType::Automatic { self.update_check_type = check_type; @@ -511,6 +516,15 @@ impl AutoUpdater { self.status.clone() } + pub fn dismissed_status(&self) -> Option { + self.dismissed_status.clone() + } + + pub fn dismiss_status(&mut self, status: AutoUpdateStatus, cx: &mut Context) { + self.dismissed_status = Some(status); + cx.notify(); + } + pub fn dismiss(&mut self, cx: &mut Context) -> bool { if let AutoUpdateStatus::Idle = self.status { return false; diff --git a/crates/title_bar/src/update_version.rs b/crates/title_bar/src/update_version.rs index 622572abad3..876c00b9ba6 100644 --- a/crates/title_bar/src/update_version.rs +++ b/crates/title_bar/src/update_version.rs @@ -9,31 +9,30 @@ use ui::{UpdateButton, prelude::*}; pub struct UpdateVersion { status: AutoUpdateStatus, update_check_type: UpdateCheckType, - dismissed: bool, + dismissed_status: Option, } impl UpdateVersion { pub fn new(cx: &mut Context) -> Self { if let Some(auto_updater) = AutoUpdater::get(cx) { cx.observe(&auto_updater, |this, auto_update, cx| { - this.status = auto_update.read(cx).status(); - this.update_check_type = auto_update.read(cx).update_check_type(); - if this.status.is_updated() { - this.dismissed = false; - } + let auto_update = auto_update.read(cx); + this.status = auto_update.status(); + this.update_check_type = auto_update.update_check_type(); + this.dismissed_status = auto_update.dismissed_status(); cx.notify(); }) .detach(); Self { status: auto_updater.read(cx).status(), update_check_type: UpdateCheckType::Automatic, - dismissed: false, + dismissed_status: auto_updater.read(cx).dismissed_status(), } } else { Self { status: AutoUpdateStatus::Idle, update_check_type: UpdateCheckType::Automatic, - dismissed: false, + dismissed_status: None, } } } @@ -59,12 +58,27 @@ impl UpdateVersion { self.status = next_state; self.update_check_type = UpdateCheckType::Manual; - self.dismissed = false; + self.dismissed_status = None; cx.notify() } pub fn show_update_in_menu_bar(&self) -> bool { - self.dismissed && self.status.is_updated() + self.is_dismissed() && self.status.is_updated() + } + + fn is_dismissed(&self) -> bool { + self.dismissed_status.as_ref() == Some(&self.status) + } + + fn dismiss(&mut self, cx: &mut Context) { + self.dismissed_status = Some(self.status.clone()); + if let Some(auto_updater) = AutoUpdater::get(cx) { + let status = self.status.clone(); + auto_updater.update(cx, |auto_updater, cx| { + auto_updater.dismiss_status(status, cx) + }); + } + cx.notify() } fn version_tooltip_message(version: &Version) -> String { @@ -74,7 +88,7 @@ impl UpdateVersion { impl Render for UpdateVersion { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - if self.dismissed { + if self.is_dismissed() { return Empty.into_any_element(); } match &self.status { @@ -95,10 +109,7 @@ impl Render for UpdateVersion { .on_click(|_, _, cx| { workspace::reload(cx); }) - .on_dismiss(cx.listener(|this, _, _window, cx| { - this.dismissed = true; - cx.notify() - })) + .on_dismiss(cx.listener(|this, _, _window, cx| this.dismiss(cx))) .into_any_element() } AutoUpdateStatus::Errored { error } => { @@ -107,10 +118,7 @@ impl Render for UpdateVersion { .on_click(|_, window, cx| { window.dispatch_action(Box::new(workspace::OpenLog), cx); }) - .on_dismiss(cx.listener(|this, _, _window, cx| { - this.dismissed = true; - cx.notify() - })) + .on_dismiss(cx.listener(|this, _, _window, cx| this.dismiss(cx))) .into_any_element() } AutoUpdateStatus::Idle | AutoUpdateStatus::Checking { .. } => Empty.into_any_element(), From 98fe76caadfd71d09eb29de885d0e7b439956e38 Mon Sep 17 00:00:00 2001 From: davidhi7 <77309510+davidhi7@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:22:14 +0200 Subject: [PATCH 093/197] editor: Fix completion labels not being rendered completely (#56976) PR https://github.com/zed-industries/zed/pull/45892 changed the logic for rendering completion labels and divided rendering up into the "main" part (consisting of the completion's `filter_range`) and "details" part (everything after `filter_range`), but no longer renders text that comes before the `filter_range`. This text is probably not relevant in too many cases but makes some Rust completions unclear and ambiguous, as can be seen in this example: Without this change: Image With this change: grafik 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 #56973 Release Notes: - Fix completion labels not being rendered completely --------- Co-authored-by: Smit Barmase --- crates/editor/src/code_context_menus.rs | 125 +++++++++++++++++------- 1 file changed, 89 insertions(+), 36 deletions(-) diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index 25ac00a495c..b192fa3d683 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -1,9 +1,9 @@ use crate::scroll::ScrollAmount; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ - AnyElement, Entity, Focusable, FontWeight, ListSizingBehavior, ScrollHandle, ScrollStrategy, - SharedString, Size, StrikethroughStyle, StyledText, Task, TaskExt, UniformListScrollHandle, - div, px, uniform_list, + AnyElement, Entity, Focusable, FontWeight, HighlightStyle, ListSizingBehavior, ScrollHandle, + ScrollStrategy, SharedString, Size, StrikethroughStyle, StyledText, Task, TaskExt, + UniformListScrollHandle, div, px, uniform_list, }; use itertools::Itertools; use language::CodeLabel; @@ -1006,43 +1006,18 @@ impl CompletionsMenu { let highlights: Vec<_> = highlights.collect(); - let filter_range = &completion.label.filter_range; - let full_text = &completion.label.text; - - let main_text: String = full_text[filter_range.clone()].to_string(); - let main_highlights: Vec<_> = highlights - .iter() - .filter_map(|(range, highlight)| { - if range.end <= filter_range.start - || range.start >= filter_range.end - { - return None; - } - let clamped_start = - range.start.max(filter_range.start) - filter_range.start; - let clamped_end = - range.end.min(filter_range.end) - filter_range.start; - Some((clamped_start..clamped_end, (*highlight))) - }) - .collect(); - let main_label = StyledText::new(main_text) + let ((main_text, main_highlights), (suffix_text, suffix_highlights)) = + split_completion_label( + &completion.label.text, + &completion.label.filter_range, + &highlights, + ); + let main_label = StyledText::new(main_text.to_string()) .with_default_highlights(&style.text, main_highlights); - let suffix_text: String = full_text[filter_range.end..].to_string(); - let suffix_highlights: Vec<_> = highlights - .iter() - .filter_map(|(range, highlight)| { - if range.end <= filter_range.end { - return None; - } - let shifted_start = range.start.saturating_sub(filter_range.end); - let shifted_end = range.end - filter_range.end; - Some((shifted_start..shifted_end, (*highlight))) - }) - .collect(); let suffix_label = if !suffix_text.is_empty() { Some( - StyledText::new(suffix_text) + StyledText::new(suffix_text.to_string()) .with_default_highlights(&style.text, suffix_highlights), ) } else { @@ -1691,6 +1666,42 @@ fn completion_kind_highlight_name(kind: CompletionItemKind) -> Option<&'static s }) } +fn split_completion_label<'a>( + text: &'a str, + filter_range: &Range, + highlights: &[(Range, HighlightStyle)], +) -> ( + (&'a str, Vec<(Range, HighlightStyle)>), + (&'a str, Vec<(Range, HighlightStyle)>), +) { + let (main_text, suffix_text) = text.split_at(filter_range.end); + let main_highlights = highlights + .iter() + .filter_map(|(range, highlight)| { + if range.start >= filter_range.end { + return None; + } + let clamped_end = range.end.min(filter_range.end); + Some((range.start..clamped_end, *highlight)) + }) + .collect(); + let suffix_highlights = highlights + .iter() + .filter_map(|(range, highlight)| { + if range.end <= filter_range.end { + return None; + } + let shifted_start = range.start.saturating_sub(filter_range.end); + let shifted_end = range.end - filter_range.end; + Some((shifted_start..shifted_end, *highlight)) + }) + .collect(); + ( + (main_text, main_highlights), + (suffix_text, suffix_highlights), + ) +} + fn exact_case_match_count(query: &str, string_match: &StringMatch) -> usize { let mut exact_matches = 0; let mut query_chars = query.chars(); @@ -2038,3 +2049,45 @@ impl CodeActionsMenu { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn bold() -> HighlightStyle { + FontWeight::BOLD.into() + } + + fn colored() -> HighlightStyle { + HighlightStyle { + fade_out: Some(0.5), + ..Default::default() + } + } + + #[test] + fn test_split_completion_label_keeps_prefix_before_filter_range() { + let ((main_text, main_highlights), (suffix_text, suffix_highlights)) = + split_completion_label("&some_str: String", &(1..9), &[(11..17, colored())]); + + assert_eq!(main_text, "&some_str"); + assert_eq!(suffix_text, ": String"); + assert_eq!(main_highlights, vec![]); + assert_eq!(suffix_highlights, vec![(2..8, colored())]); + } + + #[test] + fn test_split_completion_label_splits_boundary_spanning_highlight() { + let ((main_text, main_highlights), (suffix_text, suffix_highlights)) = + split_completion_label( + "await.as_deref_mut(&mut self)", + &(6..18), + &[(0..29, bold())], + ); + + assert_eq!(main_text, "await.as_deref_mut"); + assert_eq!(suffix_text, "(&mut self)"); + assert_eq!(main_highlights, vec![(0..18, bold())]); + assert_eq!(suffix_highlights, vec![(0..11, bold())]); + } +} From 9064f26a454f8aa32c126d8c798e1063f3f95210 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 6 Jul 2026 17:27:24 -0400 Subject: [PATCH 094/197] cloud_api_types: Add new ID fields to `AuthenticatedUser` (#60497) This PR adds the new `id_v2` and `legacy_user_id` fields to the `AuthenticatedUser` type and starts using them in place of the `id` field. Release Notes: - N/A --- crates/client/src/test.rs | 3 ++- crates/cloud_api_types/src/cloud_api_types.rs | 3 ++- crates/collab/src/auth.rs | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/client/src/test.rs b/crates/client/src/test.rs index 858bf499cd5..1770bce2302 100644 --- a/crates/client/src/test.rs +++ b/crates/client/src/test.rs @@ -240,7 +240,8 @@ pub fn make_get_authenticated_user_response( ) -> GetAuthenticatedUserResponse { GetAuthenticatedUserResponse { user: AuthenticatedUser { - id: user_id, + id_v2: format!("user_{user_id}"), + legacy_user_id: user_id, metrics_id: format!("metrics-id-{user_id}"), username: username.clone(), avatar_url: "".to_string(), diff --git a/crates/cloud_api_types/src/cloud_api_types.rs b/crates/cloud_api_types/src/cloud_api_types.rs index 0de0a5444d9..b02f5756cce 100644 --- a/crates/cloud_api_types/src/cloud_api_types.rs +++ b/crates/cloud_api_types/src/cloud_api_types.rs @@ -36,7 +36,8 @@ pub struct GetAuthenticatedUserResponse { #[derive(Debug, PartialEq, Serialize, Deserialize)] pub struct AuthenticatedUser { - pub id: i32, + pub id_v2: String, + pub legacy_user_id: i32, pub metrics_id: String, pub username: String, pub avatar_url: String, diff --git a/crates/collab/src/auth.rs b/crates/collab/src/auth.rs index 95cb4626b3e..7f0fa5b7a8a 100644 --- a/crates/collab/src/auth.rs +++ b/crates/collab/src/auth.rs @@ -67,7 +67,7 @@ pub async fn validate_header(mut req: Request, next: Next) -> impl Into .context("failed to parse response body")?; let user = User { - id: UserId(response_body.user.id), + id: UserId(response_body.user.legacy_user_id), username: response_body.user.username, github_login: response_body.user.github_login, avatar_url: response_body.user.avatar_url, From e7311d52ba1b7ec8f2c1651e32bd78e0da4cbca9 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Mon, 6 Jul 2026 17:12:33 -0500 Subject: [PATCH 095/197] Split interpolate failure rejection reason (#60499) ## Summary - Added `InterpolateFailed` as a distinct edit prediction rejection reason. - Kept `InterpolatedEmpty` for successful interpolation that leaves no edits. - Updated interpolation callers to classify failed interpolation separately from empty edit vectors. Release Notes: - N/A --- .../cloud_llm_client/src/cloud_llm_client.rs | 2 + crates/codestral/src/codestral.rs | 3 +- .../src/edit_prediction_tests.rs | 72 ++++++++++++++++++- crates/edit_prediction/src/prediction.rs | 46 +++++++----- .../src/zed_edit_prediction_delegate.rs | 11 ++- .../src/edit_prediction_types.rs | 2 +- 6 files changed, 112 insertions(+), 24 deletions(-) diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs index a33a2e8cc1a..2bc734cc10e 100644 --- a/crates/cloud_llm_client/src/cloud_llm_client.rs +++ b/crates/cloud_llm_client/src/cloud_llm_client.rs @@ -196,6 +196,8 @@ pub enum EditPredictionRejectReason { Empty, /// Edits returned, but none remained after interpolation InterpolatedEmpty, + /// Edits returned, but could not be interpolated after buffer changes + InterpolateFailed, /// The new prediction was preferred over the current one Replaced, /// The current prediction was preferred over the new one diff --git a/crates/codestral/src/codestral.rs b/crates/codestral/src/codestral.rs index 64de772aec1..c5889ae6663 100644 --- a/crates/codestral/src/codestral.rs +++ b/crates/codestral/src/codestral.rs @@ -82,9 +82,10 @@ struct CurrentCompletion { impl CurrentCompletion { /// Attempts to adjust the edits based on changes made to the buffer since the completion was generated. - /// Returns None if the user's edits conflict with the predicted edits. + /// Returns None if no predicted edits remain or the user's edits conflict with the predicted edits. fn interpolate(&self, new_snapshot: &BufferSnapshot) -> Option, Arc)>> { edit_prediction_types::interpolate_edits(&self.snapshot, new_snapshot, &self.edits) + .filter(|edits| !edits.is_empty()) } } diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index a2a196d1352..dd8b866a6b1 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -1389,7 +1389,7 @@ async fn test_interpolated_empty(cx: &mut TestAppContext) { let (request, respond_tx) = requests.predict.next().await.unwrap(); buffer.update(cx, |buffer, cx| { - buffer.set_text("Hello!\nHow are you?\nBye", cx); + buffer.edit([(10..10, " are you?")], None, cx); }); let mut response = model_response(&request, SIMPLE_DIFF); @@ -1412,7 +1412,6 @@ async fn test_interpolated_empty(cx: &mut TestAppContext) { assert!(shown_predictions[0].editable_range.is_some()); }); - // prediction is reported as rejected let (reject_request, _) = requests.reject.next().await.unwrap(); assert_eq!( @@ -1427,6 +1426,75 @@ async fn test_interpolated_empty(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn test_interpolate_failed(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.md": "Hello!\nHow\nBye\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot.anchor_before(language::Point::new(1, 3)); + + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer( + project.clone(), + buffer.clone(), + position, + EditPredictionRequestTrigger::Other, + cx, + ); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + + buffer.update(cx, |buffer, cx| { + buffer.edit([(10..10, " is it?")], None, cx); + }); + + let mut response = model_response(&request, SIMPLE_DIFF); + response.model_version = Some("zeta2:test-interpolate-failed".to_string()); + let id = response.request_id.clone(); + respond_tx.send(response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + assert!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .is_none() + ); + assert!(ep_store.rateable_predictions().next().is_none()); + }); + + let (reject_request, _) = requests.reject.next().await.unwrap(); + + assert_eq!( + &reject_request.rejections, + &[EditPredictionRejection { + request_id: id, + reason: EditPredictionRejectReason::InterpolateFailed, + was_shown: false, + model_version: Some("zeta2:test-interpolate-failed".to_string()), + e2e_latency_ms: Some(0), + }] + ); +} + const SIMPLE_DIFF: &str = indoc! { r" --- a/root/foo.md +++ b/root/foo.md diff --git a/crates/edit_prediction/src/prediction.rs b/crates/edit_prediction/src/prediction.rs index ca6379fa6ee..0f7896bc882 100644 --- a/crates/edit_prediction/src/prediction.rs +++ b/crates/edit_prediction/src/prediction.rs @@ -49,27 +49,35 @@ impl EditPredictionResult { e2e_latency: std::time::Duration, cx: &mut AsyncApp, ) -> Self { - let (edits, new_snapshot) = (!edits.is_empty()) - .then(|| { - edited_buffer.read_with(cx, |buffer, _cx| { - let new_snapshot = buffer.snapshot(); - let edits: Arc<[(Range, Arc)]> = - interpolate_edits(&edited_buffer_snapshot, &new_snapshot, &edits) - .map(Arc::from) - .unwrap_or_default(); - let snapshot = (!edits.is_empty()).then_some(new_snapshot); - (Some(edits), snapshot) - }) + let (edits, reject_reason, new_snapshot): ( + Arc<[(Range, Arc)]>, + Option, + Option, + ) = if edits.is_empty() { + ( + Arc::default(), + Some(EditPredictionRejectReason::Empty), + None, + ) + } else { + edited_buffer.read_with(cx, |buffer, _cx| { + let new_snapshot = buffer.snapshot(); + match interpolate_edits(&edited_buffer_snapshot, &new_snapshot, &edits) { + Some(edits) if edits.is_empty() => ( + Arc::default(), + Some(EditPredictionRejectReason::InterpolatedEmpty), + None, + ), + Some(edits) => (Arc::from(edits), None, Some(new_snapshot)), + None => ( + Arc::default(), + Some(EditPredictionRejectReason::InterpolateFailed), + None, + ), + } }) - .unwrap_or_default(); - let snapshot = new_snapshot.unwrap_or_else(|| edited_buffer_snapshot.clone()); - - let reject_reason = match edits.as_ref() { - None => Some(EditPredictionRejectReason::Empty), - Some(edits) if edits.is_empty() => Some(EditPredictionRejectReason::InterpolatedEmpty), - Some(_) => None, }; - let edits = edits.unwrap_or_default(); + let snapshot = new_snapshot.unwrap_or_else(|| edited_buffer_snapshot.clone()); let edit_preview = if !edits.is_empty() { edited_buffer diff --git a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs index c3cb556c7b1..b7ae7d629ec 100644 --- a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs +++ b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs @@ -222,13 +222,22 @@ impl EditPredictionDelegate for ZedEditPredictionDelegate { let Some(edits) = prediction.interpolate(&snapshot) else { store.reject_current_prediction( - EditPredictionRejectReason::InterpolatedEmpty, + EditPredictionRejectReason::InterpolateFailed, &self.project, cx, ); return None; }; + if edits.is_empty() { + store.reject_current_prediction( + EditPredictionRejectReason::InterpolatedEmpty, + &self.project, + cx, + ); + return None; + } + let cursor_row = cursor_position.to_point(&snapshot).row; let (closest_edit_ix, (closest_edit_range, _)) = edits.iter().enumerate().min_by_key(|(_, (range, _))| { diff --git a/crates/edit_prediction_types/src/edit_prediction_types.rs b/crates/edit_prediction_types/src/edit_prediction_types.rs index a285e8aa70a..bcd1f5d8e62 100644 --- a/crates/edit_prediction_types/src/edit_prediction_types.rs +++ b/crates/edit_prediction_types/src/edit_prediction_types.rs @@ -392,5 +392,5 @@ pub fn interpolate_edits( edits.extend(model_edits.cloned()); - if edits.is_empty() { None } else { Some(edits) } + Some(edits) } From 52bc5a0488d4964a5981cbd923409d55341b088a Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Jul 2026 21:20:27 -0400 Subject: [PATCH 096/197] run_tests: Stop treating non-workspace dirs as packages (#60502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `orchestrate` job maps changed directories to root-workspace package names, and when no mapping was found it fell back to using the raw directory name as a package. Because `tooling/lints` is a separate workspace (not a root-workspace member), a change under `tooling/lints/**` produced the filter `rdeps(lints)`, which `cargo nextest run --workspace` rejects with "operator didn't match any packages" — failing `run_tests` for any such PR. This drops that fallback so an unmapped directory falls through to the existing "no package changes → run all tests" path instead. Release Notes: - N/A --- .github/workflows/run_tests.yml | 11 ++++++++--- tooling/xtask/src/tasks/workflows/run_tests.rs | 11 ++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 1b8f416baab..319c7290dd7 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -76,11 +76,16 @@ jobs: FILE_CHANGED_PKGS="" for dir in $CHANGED_DIRS; do pkg=$(echo "$DIR_TO_PKG" | grep "^${dir}=" | cut -d= -f2 | head -1 || true) + # Only add directories that map to a real root-workspace package. + # Some directories (e.g. tooling/lints) belong to a separate workspace + # and are not root members, so they have no mapping here. Previously we + # fell back to the raw directory name, which fabricated a bogus package + # (e.g. "lints") and produced a nextest filter like rdeps(lints) that + # hard-errors ("operator didn't match any packages"). Skipping such + # directories leaves the package set empty, which falls through to the + # "run all tests" path below. if [ -n "$pkg" ]; then FILE_CHANGED_PKGS=$(printf '%s\n%s' "$FILE_CHANGED_PKGS" "$pkg") - else - # Fall back to directory name if no mapping found - FILE_CHANGED_PKGS=$(printf '%s\n%s' "$FILE_CHANGED_PKGS" "$dir") fi done FILE_CHANGED_PKGS=$(echo "$FILE_CHANGED_PKGS" | grep -v '^$' | sort -u || true) diff --git a/tooling/xtask/src/tasks/workflows/run_tests.rs b/tooling/xtask/src/tasks/workflows/run_tests.rs index 84dc6692392..62043c545aa 100644 --- a/tooling/xtask/src/tasks/workflows/run_tests.rs +++ b/tooling/xtask/src/tasks/workflows/run_tests.rs @@ -222,11 +222,16 @@ fn orchestrate_impl(rules: &[&PathCondition], target: OrchestrateTarget) -> Name FILE_CHANGED_PKGS="" for dir in $CHANGED_DIRS; do pkg=$(echo "$DIR_TO_PKG" | grep "^${dir}=" | cut -d= -f2 | head -1 || true) + # Only add directories that map to a real root-workspace package. + # Some directories (e.g. tooling/lints) belong to a separate workspace + # and are not root members, so they have no mapping here. Previously we + # fell back to the raw directory name, which fabricated a bogus package + # (e.g. "lints") and produced a nextest filter like rdeps(lints) that + # hard-errors ("operator didn't match any packages"). Skipping such + # directories leaves the package set empty, which falls through to the + # "run all tests" path below. if [ -n "$pkg" ]; then FILE_CHANGED_PKGS=$(printf '%s\n%s' "$FILE_CHANGED_PKGS" "$pkg") - else - # Fall back to directory name if no mapping found - FILE_CHANGED_PKGS=$(printf '%s\n%s' "$FILE_CHANGED_PKGS" "$dir") fi done FILE_CHANGED_PKGS=$(echo "$FILE_CHANGED_PKGS" | grep -v '^$' | sort -u || true) From 872ca8fef52fe527fc922e8bf61e93201de79878 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Jul 2026 21:35:06 -0400 Subject: [PATCH 097/197] Add license symlinks to lint test fixture crates (#60505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #60502 and targets it — #60502 must land first (its orchestrator fix is what lets this `tooling/lints/`-touching PR go green rather than tripping the `rdeps(lints)` nextest filter). Re-creates the symlinks from #60504, which was accidentally merged into #60502's branch and reverted. Follow-up to #60468, which added a `LICENSE-APACHE` symlink to the `tooling/lints` crate but not to the `test_fixture` sub-crates. Those five sub-crates each carry a `Cargo.toml`, so `script/check-licenses` requires a `LICENSE-GPL`/`LICENSE-APACHE` symlink in each, and `check_licenses` fails on any PR that changes `Cargo.lock`. This adds `LICENSE-APACHE` symlinks to the five fixture crates, matching #60468; `script/check-licenses` passes locally afterward. Release Notes: - N/A --- tooling/lints/test_fixture/LICENSE-APACHE | 1 + tooling/lints/test_fixture/consumer/LICENSE-APACHE | 1 + tooling/lints/test_fixture/gpui/LICENSE-APACHE | 1 + tooling/lints/test_fixture/gpui_shared_string/LICENSE-APACHE | 1 + tooling/lints/test_fixture/render_consumer/LICENSE-APACHE | 1 + 5 files changed, 5 insertions(+) create mode 120000 tooling/lints/test_fixture/LICENSE-APACHE create mode 120000 tooling/lints/test_fixture/consumer/LICENSE-APACHE create mode 120000 tooling/lints/test_fixture/gpui/LICENSE-APACHE create mode 120000 tooling/lints/test_fixture/gpui_shared_string/LICENSE-APACHE create mode 120000 tooling/lints/test_fixture/render_consumer/LICENSE-APACHE diff --git a/tooling/lints/test_fixture/LICENSE-APACHE b/tooling/lints/test_fixture/LICENSE-APACHE new file mode 120000 index 00000000000..6e2b1da8755 --- /dev/null +++ b/tooling/lints/test_fixture/LICENSE-APACHE @@ -0,0 +1 @@ +../../../LICENSE-APACHE \ No newline at end of file diff --git a/tooling/lints/test_fixture/consumer/LICENSE-APACHE b/tooling/lints/test_fixture/consumer/LICENSE-APACHE new file mode 120000 index 00000000000..15824831a24 --- /dev/null +++ b/tooling/lints/test_fixture/consumer/LICENSE-APACHE @@ -0,0 +1 @@ +../../../../LICENSE-APACHE \ No newline at end of file diff --git a/tooling/lints/test_fixture/gpui/LICENSE-APACHE b/tooling/lints/test_fixture/gpui/LICENSE-APACHE new file mode 120000 index 00000000000..15824831a24 --- /dev/null +++ b/tooling/lints/test_fixture/gpui/LICENSE-APACHE @@ -0,0 +1 @@ +../../../../LICENSE-APACHE \ No newline at end of file diff --git a/tooling/lints/test_fixture/gpui_shared_string/LICENSE-APACHE b/tooling/lints/test_fixture/gpui_shared_string/LICENSE-APACHE new file mode 120000 index 00000000000..15824831a24 --- /dev/null +++ b/tooling/lints/test_fixture/gpui_shared_string/LICENSE-APACHE @@ -0,0 +1 @@ +../../../../LICENSE-APACHE \ No newline at end of file diff --git a/tooling/lints/test_fixture/render_consumer/LICENSE-APACHE b/tooling/lints/test_fixture/render_consumer/LICENSE-APACHE new file mode 120000 index 00000000000..15824831a24 --- /dev/null +++ b/tooling/lints/test_fixture/render_consumer/LICENSE-APACHE @@ -0,0 +1 @@ +../../../../LICENSE-APACHE \ No newline at end of file From c31b2b0dc7180247b2981eb084594efaf11ee396 Mon Sep 17 00:00:00 2001 From: drbh Date: Tue, 7 Jul 2026 02:55:29 -0400 Subject: [PATCH 098/197] Git partially staged changes (#46541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR explores the addition of a new feature and UI to improve visibility into partially staged commits. Currently, the Git panel shows tracked and untracked changes, but it does not clearly distinguish between staged and unstaged changes. As a result, it’s difficult to quickly see which changes are not staged in the current UI. Both staged and unstaged changes are combined into the `Uncommitted Changes` multibuffer. This developer experience differs from other editors, most notably VS Code; which presents separate Staged Changes and Changes lists. ### Staged and unstaged diffs in multibuffers This PR introduces an alternative UI for unstaged changes that aligns with the overall Zed experience. Instead of showing changes on a per-file basis, staged and unstaged diffs are each displayed in their own multibuffers, similar to how `Uncommitted Changes` currently works. For example the following screenshot shows the current `Uncommitted Changes` on the left, the `Staged Changes` in the middle and the `Unstaged Changes` buffer on the right for comparison ### Indicators/interactions The new multibuffers can be opened in two ways: 1. Via a new `U` chip, which appears when a file has unstaged changes 2. Via new menu options (See screenshots below for both interaction paths.)

via the chip

Via the chip

via the menu

Via the menu
### Design goals - minimally intrusive UI changes (small new badge and menu items) - adhere by Zed'ism (use multibuffer where possible) - avoid disabling any current interactions (Uncommitted Changes ui is unchanged) - avoid introducing an app level view mode (no new settings needed) ### Experience goals - make it easy to see what changes are not staged - make it easy to see that a file has unstaged changes (avoid developers accidently leaving out changes in a commit; a personal issue that I have when using Zed) - elegantly handle large file's unstaged changes (follows the same collapse and expanding seen in `Uncommitted Changes`) ### How to try - Clone the repo and run `cargo run` - Make a change to a file and stage it - Make another change to the file (the `U` indicator will appear) - Click the `U` to see the unstaged view ### Open questions/rough edges - [ ] determine if this user experience is useful for others - [ ] ensure all interactions work as expected (response to all update cases) In general I'm really interested in hearing the community's feedback about this interface, more than happy to make any changes or explore a different solution! ### Related issue: - https://github.com/zed-industries/zed/pull/36646 - https://github.com/zed-industries/zed/issues/26560 Release Notes: - Support partially staged commit multibuffers via a staged and unstaged changes view. --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Cole Miller --- crates/agent_ui/src/agent_diff.rs | 96 +- crates/agent_ui/src/entry_view_state.rs | 9 +- crates/buffer_diff/src/buffer_diff.rs | 632 ++-- crates/editor/src/config.rs | 4 - crates/editor/src/editor.rs | 30 +- crates/editor/src/element.rs | 13 +- crates/editor/src/git.rs | 644 ++-- crates/editor/src/split.rs | 190 +- crates/git_ui/src/branch_diff.rs | 1199 ++++++++ crates/git_ui/src/commit_view.rs | 8 +- crates/git_ui/src/conflict_view.rs | 71 +- crates/git_ui/src/diff_multibuffer.rs | 1016 ++++++ crates/git_ui/src/file_diff_view.rs | 12 +- crates/git_ui/src/git_panel.rs | 58 +- crates/git_ui/src/git_ui.rs | 7 + crates/git_ui/src/multi_diff_view.rs | 12 +- crates/git_ui/src/project_diff.rs | 2726 +++++------------ crates/git_ui/src/staged_diff.rs | 1090 +++++++ crates/git_ui/src/text_diff_view.rs | 11 +- crates/git_ui/src/unstaged_diff.rs | 722 +++++ crates/multi_buffer/src/multi_buffer.rs | 5 +- crates/project/src/git_store.rs | 703 ++++- .../{branch_diff.rs => diff_buffer_list.rs} | 143 +- crates/project/src/project.rs | 57 +- .../tests/integration/project_tests.rs | 949 +++++- crates/proto/proto/git.proto | 5 + crates/search/src/buffer_search.rs | 5 +- crates/zed/src/zed.rs | 9 +- crates/zed_actions/src/lib.rs | 6 + 29 files changed, 7599 insertions(+), 2833 deletions(-) create mode 100644 crates/git_ui/src/branch_diff.rs create mode 100644 crates/git_ui/src/diff_multibuffer.rs create mode 100644 crates/git_ui/src/staged_diff.rs create mode 100644 crates/git_ui/src/unstaged_diff.rs rename crates/project/src/git_store/{branch_diff.rs => diff_buffer_list.rs} (76%) diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 9f51e53b292..079b043e467 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -6,8 +6,8 @@ use anyhow::Result; use buffer_diff::DiffHunkStatus; use collections::{HashMap, HashSet}; use editor::{ - Direction, Editor, EditorEvent, EditorSettings, MultiBuffer, MultiBufferSnapshot, - SelectionEffects, SplittableEditor, ToPoint, + DiffHunkDelegate, Direction, Editor, EditorEvent, EditorSettings, MultiBuffer, + MultiBufferSnapshot, ResolvedDiffHunks, SelectionEffects, SplittableEditor, ToPoint, actions::{GoToHunk, GoToPreviousHunk}, multibuffer_context_lines, scroll::Autoscroll, @@ -101,8 +101,7 @@ impl AgentDiffPane { cx, ); diff_display_editor - .set_render_diff_hunk_controls(diff_hunk_controls(&thread, workspace.clone()), cx); - diff_display_editor.set_render_diff_hunks_as_unstaged(cx); + .set_diff_hunk_delegate(Some(agent_diff_delegate(&thread, workspace.clone())), cx); diff_display_editor.update_editors(cx, |editor, _cx| { editor.register_addon(AgentDiffAddon); }); @@ -722,29 +721,68 @@ impl Render for AgentDiffPane { } } -fn diff_hunk_controls( +struct AgentDiffDelegate { + thread: Entity, + workspace: WeakEntity, +} + +fn agent_diff_delegate( thread: &Entity, workspace: WeakEntity, -) -> editor::RenderDiffHunkControlsFn { - let thread = thread.clone(); +) -> Arc { + Arc::new(AgentDiffDelegate { + thread: thread.clone(), + workspace, + }) +} - Arc::new( - move |row, status, hunk_range, is_created_file, line_height, editor, _, cx| { - { - render_diff_hunk_controls( - row, - status, - hunk_range, - is_created_file, - line_height, - &thread, - editor, - workspace.clone(), - cx, - ) - } - }, - ) +impl DiffHunkDelegate for AgentDiffDelegate { + fn toggle( + &self, + _hunks: Vec, + _editor: &mut Editor, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn stage_or_unstage( + &self, + _stage: bool, + _hunks: Vec, + _editor: &mut Editor, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn render_hunk_controls( + &self, + row: u32, + status: &DiffHunkStatus, + hunk_range: Range, + is_created_file: bool, + line_height: Pixels, + editor: &Entity, + _window: &mut Window, + cx: &mut App, + ) -> AnyElement { + render_diff_hunk_controls( + row, + status, + hunk_range, + is_created_file, + line_height, + &self.thread, + editor, + self.workspace.clone(), + cx, + ) + } + + fn render_hunk_as_staged(&self, _status: &DiffHunkStatus, _cx: &App) -> bool { + false + } } fn render_diff_hunk_controls( @@ -1528,7 +1566,7 @@ impl AgentDiff { for (editor, _) in self.reviewing_editors.drain() { editor .update(cx, |editor, cx| { - editor.end_temporary_diff_override(cx); + editor.set_diff_hunk_delegate(None, cx); editor.unregister_addon::(); }) .ok(); @@ -1577,12 +1615,10 @@ impl AgentDiff { if previous_state.is_none() { editor.update(cx, |editor, cx| { - editor.start_temporary_diff_override(); - editor.set_render_diff_hunk_controls( - diff_hunk_controls(&thread, workspace.clone()), + editor.set_diff_hunk_delegate( + Some(agent_diff_delegate(&thread, workspace.clone())), cx, ); - editor.set_render_diff_hunks_as_unstaged(true, cx); editor.set_expand_all_diff_hunks(cx); editor.register_addon(EditorAgentDiffAddon); }); @@ -1629,7 +1665,7 @@ impl AgentDiff { if in_workspace { editor .update(cx, |editor, cx| { - editor.end_temporary_diff_override(cx); + editor.set_diff_hunk_delegate(None, cx); editor.unregister_addon::(); }) .ok(); diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index e2ad7ca71ec..faa760b73a6 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -1,11 +1,14 @@ -use std::ops::Range; +use std::{ops::Range, sync::Arc}; use acp_thread::{AcpThread, AgentThreadEntry, AssistantMessageChunk}; use agent::ThreadStore; use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentSettings; use collections::{HashMap, HashSet}; -use editor::{Editor, EditorEvent, EditorMode, MinimapVisibility, SizingBehavior}; +use editor::{ + Editor, EditorEvent, EditorMode, MinimapVisibility, RestoreOnlyUnstagedDiffHunkDelegate, + SizingBehavior, +}; use gpui::{ AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, FocusHandle, Focusable, ScrollHandle, TextStyleRefinement, WeakEntity, Window, @@ -682,7 +685,7 @@ fn create_editor_diff( editor.set_show_code_actions(false, cx); editor.set_show_git_diff_gutter(false, cx); editor.set_expand_all_diff_hunks(cx); - editor.set_render_diff_hunks_as_unstaged(true, cx); + editor.set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyUnstagedDiffHunkDelegate)), cx); editor.set_text_style_refinement(diff_editor_text_style_refinement(cx)); editor }) diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index c300ace11ae..5cba96c5a92 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -122,11 +122,37 @@ struct InternalDiffHunk { } #[derive(Debug, Clone, PartialEq, Eq)] -struct PendingHunk { +pub struct PendingHunk { buffer_range: Range, diff_base_byte_range: Range, buffer_version: clock::Global, - new_status: DiffHunkSecondaryStatus, + sense: PendingSense, +} + +impl PendingHunk { + pub fn new( + buffer_range: Range, + diff_base_byte_range: Range, + buffer_version: clock::Global, + sense: PendingSense, + ) -> Self { + Self { + buffer_range, + diff_base_byte_range, + buffer_version, + sense, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PendingSense { + /// Override the secondary status of the matched hunk (used by the + /// uncommitted diff to show a hunk as staging/unstaging in place). + SetSecondaryStatus { stage: bool }, + /// Suppress the matched hunk entirely (used by the unstaged/staged diffs so + /// that a hunk disappears the moment it is staged/unstaged). + Suppress, } #[derive(Debug, Clone)] @@ -294,6 +320,56 @@ impl BufferDiffSnapshot { self.hunks_intersecting_range_impl(filter, buffer, unstaged_counterpart) } + /// Like [`hunks_intersecting_range`], but ignores optimistic pending hunks + /// (both secondary-status overrides and suppressions) and does not compute a + /// secondary status. + pub fn raw_hunks_intersecting_range<'a>( + &'a self, + range: Range, + buffer: &'a text::BufferSnapshot, + ) -> impl 'a + Iterator { + let range = range.to_offset(buffer); + let filter = move |summary: &DiffHunkSummary| { + let summary_range = summary.buffer_range.to_offset(buffer); + !(summary_range.end < range.start) && !(summary_range.start > range.end) + }; + self.hunks + .filter::<_, DiffHunkSummary>(buffer, filter) + .map(move |hunk| { + let buffer_range = hunk.buffer_range.clone(); + DiffHunk { + range: buffer_range.to_point(buffer), + diff_base_byte_range: hunk.diff_base_byte_range.clone(), + buffer_range, + secondary_status: DiffHunkSecondaryStatus::NoSecondaryHunk, + base_word_diffs: hunk.base_word_diffs.clone(), + buffer_word_diffs: hunk.buffer_word_diffs.clone(), + } + }) + } + + /// Maps a range in this diff's main buffer to the range it covers in the + /// base text, expanding to whole hunks wherever the range endpoints fall + /// inside or touch a hunk (`edit_for_old_position` is inclusive on both + /// boundaries, matching `raw_hunks_intersecting_range`). Used by the + /// index-write path to compute the index-coordinate footprint of a staging + /// operation; like the raw hunks, the mapping ignores optimistic pending + /// hunks. + pub fn base_text_range_for_buffer_range( + &self, + range: Range, + buffer: &text::BufferSnapshot, + ) -> Range { + let point_range = range.to_point(buffer); + let patch = self.patch_for_buffer_range(point_range.start..=point_range.end, buffer); + let start_point = patch.edit_for_old_position(point_range.start).new.start; + let end_point = patch.edit_for_old_position(point_range.end).new.end; + let base_text = self.base_text(); + let start = base_text.point_to_offset(start_point.min(base_text.max_point())); + let end = base_text.point_to_offset(end_point.min(base_text.max_point())); + start.min(end)..end + } + pub fn hunks_intersecting_range_rev<'a>( &'a self, range: Range, @@ -705,114 +781,80 @@ impl BufferDiffSnapshot { } impl BufferDiffSnapshot { - fn stage_or_unstage_hunks_impl( - &mut self, + // Compute the edits to apply to the index, and the resulting pending hunks, + // for a stage or unstage operation on the uncommitted diff. + pub fn compute_uncommitted_index_edits( + &self, unstaged_diff: &Self, stage: bool, hunks: &[DiffHunk], buffer: &text::BufferSnapshot, file_exists: bool, - ) -> Option { + ) -> (Option, Arc)>>, Vec) { let head_text = self .base_text_exists .then(|| self.base_text.as_rope().clone()); let index_text = unstaged_diff .base_text_exists .then(|| unstaged_diff.base_text.as_rope().clone()); + let sense = PendingSense::SetSecondaryStatus { stage }; + let version = buffer.version().clone(); // If the file doesn't exist in either HEAD or the index, then the // entire file must be either created or deleted in the index. let (index_text, head_text) = match (index_text, head_text) { (Some(index_text), Some(head_text)) if file_exists || !stage => (index_text, head_text), (index_text, head_text) => { - let (new_index_text, new_status) = if stage { + let index_len = index_text.as_ref().map_or(0, |rope| rope.len()); + let new_index_text: Option = if stage { log::debug!("stage all"); - ( - file_exists.then(|| buffer.as_rope().clone()), - DiffHunkSecondaryStatus::SecondaryHunkRemovalPending, - ) + file_exists.then(|| buffer.as_rope().clone()) } else { log::debug!("unstage all"); - ( - head_text, - DiffHunkSecondaryStatus::SecondaryHunkAdditionPending, - ) + head_text }; - let hunk = PendingHunk { - buffer_range: Anchor::min_max_range_for_buffer(buffer.remote_id()), - diff_base_byte_range: 0..index_text.map_or(0, |rope| rope.len()), - buffer_version: buffer.version().clone(), - new_status, - }; - self.pending_hunks = SumTree::from_item(hunk, buffer); - return new_index_text; + let pending = vec![PendingHunk::new( + Anchor::min_max_range_for_buffer(buffer.remote_id()), + 0..index_len, + version, + sense, + )]; + let edits = + new_index_text.map(|rope| vec![(0..index_len, Arc::from(rope.to_string()))]); + return (edits, pending); } }; - let mut pending_hunks = SumTree::new(buffer); - let mut old_pending_hunks = self.pending_hunks.cursor::(buffer); - - // first, merge new hunks into pending_hunks - for DiffHunk { - buffer_range, - diff_base_byte_range, - secondary_status, - .. - } in hunks.iter().cloned() - { - let preceding_pending_hunks = old_pending_hunks.slice(&buffer_range.start, Bias::Left); - pending_hunks.append(preceding_pending_hunks, buffer); - - // Skip all overlapping or adjacent old pending hunks - while old_pending_hunks.item().is_some_and(|old_hunk| { - old_hunk - .buffer_range - .start - .cmp(&buffer_range.end, buffer) - .is_le() - }) { - old_pending_hunks.next(); - } - - if (stage && secondary_status == DiffHunkSecondaryStatus::NoSecondaryHunk) - || (!stage && secondary_status == DiffHunkSecondaryStatus::HasSecondaryHunk) - { - continue; - } - - pending_hunks.push( - PendingHunk { - buffer_range, - diff_base_byte_range, - buffer_version: buffer.version().clone(), - new_status: if stage { - DiffHunkSecondaryStatus::SecondaryHunkRemovalPending - } else { - DiffHunkSecondaryStatus::SecondaryHunkAdditionPending - }, - }, - buffer, - ); - } - // append the remainder - pending_hunks.append(old_pending_hunks.suffix(), buffer); - let mut unstaged_hunk_cursor = unstaged_diff.hunks.cursor::(buffer); unstaged_hunk_cursor.next(); - // then, iterate over all pending hunks (both new ones and the existing ones) and compute the edits let mut prev_unstaged_hunk_buffer_end = 0; let mut prev_unstaged_hunk_base_text_end = 0; - let mut edits = Vec::<(Range, String)>::new(); - let mut pending_hunks_iter = pending_hunks.iter().cloned().peekable(); - while let Some(PendingHunk { - buffer_range, - diff_base_byte_range, - new_status, - .. - }) = pending_hunks_iter.next() - { + let mut edits = Vec::<(Range, Arc)>::new(); + let mut pending = Vec::::new(); + + // Process only the hunks the user acted on, skipping any already in the + // desired state. + let mut hunks_iter = hunks + .iter() + .filter(|hunk| { + !((stage && hunk.secondary_status == DiffHunkSecondaryStatus::NoSecondaryHunk) + || (!stage + && hunk.secondary_status == DiffHunkSecondaryStatus::HasSecondaryHunk)) + }) + .peekable(); + + while let Some(hunk) = hunks_iter.next() { + let buffer_range = hunk.buffer_range.clone(); + let diff_base_byte_range = hunk.diff_base_byte_range.clone(); + pending.push(PendingHunk::new( + buffer_range.clone(), + diff_base_byte_range.clone(), + version.clone(), + sense, + )); + // Advance unstaged_hunk_cursor to skip unstaged hunks before current hunk let skipped_unstaged = unstaged_hunk_cursor.slice(&buffer_range.start, Bias::Left); @@ -846,16 +888,20 @@ impl BufferDiffSnapshot { } } - // If any unstaged hunks were merged, then subsequent pending hunks may - // now overlap this hunk. Merge them. - if let Some(next_pending_hunk) = pending_hunks_iter.peek() { - let next_pending_hunk_offset_range = - next_pending_hunk.buffer_range.to_offset(buffer); - if next_pending_hunk_offset_range.start <= buffer_offset_range.end { - buffer_offset_range.end = buffer_offset_range - .end - .max(next_pending_hunk_offset_range.end); - pending_hunks_iter.next(); + // If any unstaged hunks were merged, then subsequent acted-on hunks + // may now overlap this hunk. Merge them. + if let Some(next_hunk) = hunks_iter.peek() { + let next_hunk_offset_range = next_hunk.buffer_range.to_offset(buffer); + if next_hunk_offset_range.start <= buffer_offset_range.end { + buffer_offset_range.end = + buffer_offset_range.end.max(next_hunk_offset_range.end); + let merged_hunk = hunks_iter.next().expect("peeked hunk exists"); + pending.push(PendingHunk::new( + merged_hunk.buffer_range.clone(), + merged_hunk.diff_base_byte_range.clone(), + version.clone(), + sense, + )); continue; } } @@ -879,49 +925,59 @@ impl BufferDiffSnapshot { let index_start = index_start.min(index_end); let index_byte_range = index_start..index_end; - let replacement_text = match new_status { - DiffHunkSecondaryStatus::SecondaryHunkRemovalPending => { - log::debug!("staging hunk {:?}", buffer_offset_range); + let replacement_text: Arc = if stage { + log::debug!("staging hunk {:?}", buffer_offset_range); + Arc::from( buffer .text_for_range(buffer_offset_range) - .collect::() - } - DiffHunkSecondaryStatus::SecondaryHunkAdditionPending => { - log::debug!("unstaging hunk {:?}", buffer_offset_range); + .collect::(), + ) + } else { + log::debug!("unstaging hunk {:?}", buffer_offset_range); + Arc::from( head_text .chunks_in_range(diff_base_byte_range.clone()) - .collect::() - } - _ => { - debug_assert!(false); - continue; - } + .collect::(), + ) }; - edits.push((index_byte_range, replacement_text)); + // Distinct worktree hunks can project to touching index ranges + // (e.g. a staged deletion ending exactly where the next hunk's + // index position starts). Merge them so the edit list stays + // strictly disjoint, which the pending-edit eviction logic relies + // on to not evict one of these edits when the other is inserted. + if let Some((last_range, last_text)) = edits.last_mut() + && index_byte_range.start <= last_range.end + { + debug_assert!(index_byte_range.start == last_range.end); + debug_assert!(index_byte_range.end >= last_range.end); + last_range.end = index_byte_range.end; + let mut merged_text = + String::with_capacity(last_text.len() + replacement_text.len()); + merged_text.push_str(last_text); + merged_text.push_str(&replacement_text); + *last_text = Arc::from(merged_text); + } else { + edits.push((index_byte_range, replacement_text)); + } } - drop(pending_hunks_iter); - drop(old_pending_hunks); - self.pending_hunks = pending_hunks; #[cfg(debug_assertions)] // invariants: non-overlapping and sorted { for window in edits.windows(2) { let (range_a, range_b) = (&window[0].0, &window[1].0); - debug_assert!(range_a.end < range_b.start); + debug_assert!( + range_a.end < range_b.start, + "index edits out of order or overlapping: {:?}", + edits + .iter() + .map(|(range, text)| (range.clone(), text.len())) + .collect::>() + ); } } - let mut new_index_text = Rope::new(); - let mut index_cursor = index_text.cursor(0); - - for (old_range, replacement_text) in edits { - new_index_text.append(index_cursor.slice(old_range.start)); - index_cursor.seek_forward(old_range.end); - new_index_text.push(&replacement_text); - } - new_index_text.append(index_cursor.suffix()); - Some(new_index_text) + (Some(edits), pending) } } @@ -1005,8 +1061,17 @@ impl BufferDiffSnapshot { start_anchor..end_anchor, ) { - has_pending = true; - secondary_status = pending_hunk.new_status; + match pending_hunk.sense { + PendingSense::SetSecondaryStatus { stage } => { + has_pending = true; + secondary_status = if stage { + DiffHunkSecondaryStatus::SecondaryHunkRemovalPending + } else { + DiffHunkSecondaryStatus::SecondaryHunkAdditionPending + }; + } + PendingSense::Suppress => continue, + } } } @@ -1474,7 +1539,6 @@ pub struct DiffChanged { pub enum BufferDiffEvent { BaseTextChanged, DiffChanged(DiffChanged), - HunksStagedOrUnstaged(Option), } impl EventEmitter for BufferDiff {} @@ -1594,24 +1658,195 @@ impl BufferDiff { let Some(diff_snapshot) = &mut self.diff_snapshot else { return; }; - if self.secondary_diff.is_some() { - diff_snapshot.pending_hunks = SumTree::from_summary(DiffHunkSummary { - buffer_range: Anchor::min_min_range_for_buffer(self.buffer_id), - diff_base_byte_range: 0..0, - added_rows: 0, - removed_rows: 0, - }); - let changed_range = Some(Anchor::min_max_range_for_buffer(self.buffer_id)); - let base_text_range = Some(0..self.base_text(cx).len()); + let Some((first, last)) = diff_snapshot + .pending_hunks + .first() + .zip(diff_snapshot.pending_hunks.last()) + else { + return; + }; + let changed_range = first.buffer_range.start..last.buffer_range.end; + let base_text_changed_range = + first.diff_base_byte_range.start..last.diff_base_byte_range.end; + let buffer = diff_snapshot.buffer_snapshot.clone(); + diff_snapshot.pending_hunks = SumTree::new(&buffer); + cx.emit(BufferDiffEvent::DiffChanged(DiffChanged { + changed_range: Some(changed_range.clone()), + base_text_changed_range: Some(base_text_changed_range), + extended_range: Some(changed_range), + base_text_changed: false, + })); + } + + /// Installs optimistic pending hunks in this diff, merging them with any + /// existing pending hunks (newest wins on overlap) and emitting a + /// `DiffChanged` covering both the new hunks and any existing pending hunks + /// they replace. `hunks` must be sorted by `buffer_range.start` and + /// non-overlapping. + /// + /// `buffer` must be a current snapshot of this diff's main buffer: the + /// incoming hunks carry anchors minted from the current buffer, which this + /// diff's internal snapshot (from the last settled recalculation) may not + /// have observed yet. + pub fn set_pending_hunks( + &mut self, + hunks: &[PendingHunk], + buffer: &text::BufferSnapshot, + cx: &mut Context, + ) { + if hunks.is_empty() { + return; + } + let Some(diff_snapshot) = self.diff_snapshot.as_mut() else { + return; + }; + + let mut new_pending = SumTree::new(buffer); + let mut old = diff_snapshot + .pending_hunks + .cursor::(buffer); + let mut changed_start: Option = None; + let mut changed_end: Option = None; + let mut base_start = usize::MAX; + let mut base_end = 0usize; + let mut extend_changed_range = |buffer_range: &Range, base_range: &Range| { + changed_start = Some(changed_start.map_or(buffer_range.start, |start| { + *start.min(&buffer_range.start, buffer) + })); + changed_end = Some( + changed_end.map_or(buffer_range.end, |end| *end.max(&buffer_range.end, buffer)), + ); + base_start = base_start.min(base_range.start); + base_end = base_end.max(base_range.end); + }; + for hunk in hunks { + let preceding = old.slice(&hunk.buffer_range.start, Bias::Left); + new_pending.append(preceding, buffer); + + // Drop any overlapping or adjacent existing pending hunks, folding + // them into the changed range so that views repaint their full + // extent (a replaced hunk can be wider than its replacement). + while let Some(old_hunk) = old.item() { + if old_hunk + .buffer_range + .start + .cmp(&hunk.buffer_range.end, buffer) + .is_gt() + { + break; + } + extend_changed_range(&old_hunk.buffer_range, &old_hunk.diff_base_byte_range); + old.next(); + } + + extend_changed_range(&hunk.buffer_range, &hunk.diff_base_byte_range); + new_pending.push(hunk.clone(), buffer); + } + new_pending.append(old.suffix(), buffer); + drop(old); + diff_snapshot.pending_hunks = new_pending; + + if let (Some(start), Some(end)) = (changed_start, changed_end) { + let changed_range = Some(start..end); cx.emit(BufferDiffEvent::DiffChanged(DiffChanged { changed_range: changed_range.clone(), - base_text_changed_range: base_text_range, + base_text_changed_range: Some(base_start..base_end), extended_range: changed_range, base_text_changed: false, })); } } + /// Optimistically marks every stageable (resp. unstageable) hunk in this diff + /// as staging (resp. unstaging). Used by whole-file staging from the git + /// panel, where the actual index change is performed by `git add`/`reset` + /// rather than the optimistic index patch. + pub fn mark_all_hunks_pending( + &mut self, + stage: bool, + buffer: &text::BufferSnapshot, + cx: &mut Context, + ) { + let sense = PendingSense::SetSecondaryStatus { stage }; + let version = buffer.version().clone(); + let hunks = self + .snapshot(cx) + .hunks_intersecting_range(Anchor::min_max_range_for_buffer(buffer.remote_id()), buffer) + .filter(|hunk| { + !((stage && hunk.secondary_status == DiffHunkSecondaryStatus::NoSecondaryHunk) + || (!stage + && hunk.secondary_status == DiffHunkSecondaryStatus::HasSecondaryHunk)) + }) + .map(|hunk| { + PendingHunk::new( + hunk.buffer_range, + hunk.diff_base_byte_range, + version.clone(), + sense, + ) + }) + .collect::>(); + self.set_pending_hunks(&hunks, buffer, cx); + } + + pub fn suppress_all_hunks_pending( + &mut self, + buffer: &text::BufferSnapshot, + cx: &mut Context, + ) { + let version = buffer.version().clone(); + let hunks = self + .snapshot(cx) + .raw_hunks_intersecting_range( + Anchor::min_max_range_for_buffer(buffer.remote_id()), + buffer, + ) + .map(|hunk| { + PendingHunk::new( + hunk.buffer_range, + hunk.diff_base_byte_range, + version.clone(), + PendingSense::Suppress, + ) + }) + .collect::>(); + self.set_pending_hunks(&hunks, buffer, cx); + } + + /// Computes the index-text edits for unstaging the given staged (HEAD-vs-index) + /// hunks. `index_buffer` is this diff's main buffer (the index text). The + /// returned edits are in index coordinates. + pub fn unstage_staged_hunks( + &self, + hunks: &[DiffHunk], + index_buffer: &text::BufferSnapshot, + ) -> Option, Arc)>> { + let Some(diff_snapshot) = self.diff_snapshot.as_ref() else { + return Some(Vec::new()); + }; + // With no HEAD, the whole file is one staged addition; unstaging it + // removes the file from the index entirely. + if !diff_snapshot.base_text_exists { + return None; + } + let head_text = diff_snapshot.base_text.as_rope(); + let mut edits = hunks + .iter() + .map(|hunk| { + let index_range = hunk.buffer_range.to_offset(index_buffer); + let replacement_text: Arc = Arc::from( + head_text + .chunks_in_range(hunk.diff_base_byte_range.clone()) + .collect::(), + ); + (index_range, replacement_text) + }) + .collect::>(); + edits.sort_by_key(|(range, _)| range.start); + Some(edits) + } + + #[cfg(any(test, feature = "test-support"))] pub fn stage_or_unstage_hunks( &mut self, stage: bool, @@ -1621,74 +1856,40 @@ impl BufferDiff { cx: &mut Context, ) -> Option { let secondary_diff = self.secondary_diff.clone()?; - let diff_snapshot = self.diff_snapshot.as_mut()?; let unstaged_diff_snapshot = secondary_diff.read_with(cx, |secondary_diff, _cx| { secondary_diff.diff_snapshot.clone() })?; - let new_index_text = diff_snapshot.stage_or_unstage_hunks_impl( + let diff_snapshot = self.diff_snapshot.clone()?; + let (edits, pending) = diff_snapshot.compute_uncommitted_index_edits( &unstaged_diff_snapshot, stage, hunks, buffer, file_exists, ); - - cx.emit(BufferDiffEvent::HunksStagedOrUnstaged( - new_index_text.clone(), - )); - if let Some((first, last)) = hunks.first().zip(hunks.last()) { - let changed_range = Some(first.buffer_range.start..last.buffer_range.end); - let base_text_changed_range = - Some(first.diff_base_byte_range.start..last.diff_base_byte_range.end); - cx.emit(BufferDiffEvent::DiffChanged(DiffChanged { - changed_range: changed_range.clone(), - base_text_changed_range, - extended_range: changed_range, - base_text_changed: false, - })); - } - new_index_text + self.set_pending_hunks(&pending, buffer, cx); + edits.map(|edits| { + let mut index_text = unstaged_diff_snapshot.base_text.as_rope().clone(); + for (old_range, replacement_text) in edits.iter().rev() { + index_text.replace(old_range.clone(), replacement_text); + } + index_text + }) } + #[cfg(any(test, feature = "test-support"))] pub fn stage_or_unstage_all_hunks( &mut self, stage: bool, buffer: &text::BufferSnapshot, file_exists: bool, cx: &mut Context, - ) { + ) -> Option { let hunks = self .snapshot(cx) .hunks_intersecting_range(Anchor::min_max_range_for_buffer(buffer.remote_id()), buffer) .collect::>(); - let Some(diff_snapshot) = &mut self.diff_snapshot else { - return; - }; - let Some(secondary) = self.secondary_diff.clone() else { - return; - }; - let secondary = secondary.read(cx); - let Some(secondary_snapshot) = &secondary.diff_snapshot else { - return; - }; - diff_snapshot.stage_or_unstage_hunks_impl( - &secondary_snapshot, - stage, - &hunks, - buffer, - file_exists, - ); - if let Some((first, last)) = hunks.first().zip(hunks.last()) { - let changed_range = Some(first.buffer_range.start..last.buffer_range.end); - let base_text_changed_range = - Some(first.diff_base_byte_range.start..last.diff_base_byte_range.end); - cx.emit(BufferDiffEvent::DiffChanged(DiffChanged { - changed_range: changed_range.clone(), - base_text_changed_range, - extended_range: changed_range, - base_text_changed: false, - })); - } + self.stage_or_unstage_hunks(stage, &hunks, buffer, file_exists, cx) } pub fn update_diff( @@ -2837,6 +3038,81 @@ mod tests { }); } + #[gpui::test] + async fn test_set_pending_hunks_change_covers_replaced_hunks(cx: &mut TestAppContext) { + let base_text = " + zero + one + two + three + four + five + " + .unindent(); + let buffer_text = " + ZERO + one + two + THREE + four + FIVE + " + .unindent(); + let buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), buffer_text); + let diff = cx.new(|cx| BufferDiff::new_with_base_text(&base_text, &buffer, cx)); + + // Install a whole-file pending hunk, as the no-HEAD staging paths do. + let version = buffer.version(); + diff.update(cx, |diff, cx| { + diff.set_pending_hunks( + &[PendingHunk::new( + Anchor::min_max_range_for_buffer(buffer.remote_id()), + 0..base_text.len(), + version.clone(), + PendingSense::SetSecondaryStatus { stage: true }, + )], + &buffer, + cx, + ) + }); + + let (tx, rx) = mpsc::channel(); + let subscription = + cx.update(|cx| cx.subscribe(&diff, move |_, event, _| tx.send(event.clone()).unwrap())); + + // Replace it with a narrower hunk; the emitted change must still cover + // the whole extent of the replaced hunk. + diff.update(cx, |diff, cx| { + diff.set_pending_hunks( + &[PendingHunk::new( + buffer.anchor_before(Point::new(3, 0))..buffer.anchor_before(Point::new(4, 0)), + base_text.find("three").unwrap()..base_text.find("four").unwrap(), + version, + PendingSense::Suppress, + )], + &buffer, + cx, + ) + }); + + drop(subscription); + let events = rx.into_iter().collect::>(); + match events.as_slice() { + [ + BufferDiffEvent::DiffChanged(DiffChanged { + changed_range: Some(changed_range), + .. + }), + ] => { + assert_eq!( + changed_range.to_point(&buffer), + Point::zero()..buffer.max_point(), + ); + } + _ => panic!("unexpected events: {:?}", events), + } + } + #[gpui::test] async fn test_buffer_diff_compare(cx: &mut TestAppContext) { let base_text = " diff --git a/crates/editor/src/config.rs b/crates/editor/src/config.rs index 9b5df0b8671..bfda1aae44e 100644 --- a/crates/editor/src/config.rs +++ b/crates/editor/src/config.rs @@ -360,10 +360,6 @@ impl Editor { self.delegate_expand_excerpts = delegate; } - pub(super) fn set_delegate_stage_and_restore(&mut self, delegate: bool) { - self.delegate_stage_and_restore = delegate; - } - pub(super) fn set_on_local_selections_changed( &mut self, callback: Option) + 'static>>, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 1d224ad40b4..73a0c3ebfdd 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -106,13 +106,16 @@ pub use element::{ file_status_label_color, render_breadcrumb_text, }; pub use git::blame::BlameRenderer; +pub use git::{ + DiffHunkDelegate, ResolvedDiffHunk, ResolvedDiffHunks, RestoreOnlyDiffHunkDelegate, + RestoreOnlyUnstagedDiffHunkDelegate, UncommittedDiffHunkDelegate, render_diff_hunk_controls, + set_blame_renderer, +}; pub(crate) use git::{DiffHunkKey, StoredReviewComment}; use git::{ - DiffReviewDragState, DiffReviewOverlay, InlineBlamePopover, render_diff_hunk_controls, - update_uncommitted_diff_for_buffer, + DiffReviewDragState, DiffReviewOverlay, InlineBlamePopover, update_uncommitted_diff_for_buffer, }; pub(crate) use git::{DisplayDiffHunk, PhantomDiffReviewIndicator}; -pub use git::{RenderDiffHunkControlsFn, set_blame_renderer}; pub use hover_popover::hover_markdown_style; pub use inlays::Inlay; pub use items::MAX_TAB_TITLE_LEN; @@ -973,7 +976,6 @@ pub struct Editor { offset_content: bool, disable_expand_excerpt_buttons: bool, delegate_expand_excerpts: bool, - delegate_stage_and_restore: bool, delegate_open_excerpts: bool, enable_lsp_data: bool, needs_initial_data_update: bool, @@ -1074,7 +1076,6 @@ pub struct Editor { show_git_blame_inline: bool, show_git_blame_inline_delay_task: Option>, git_blame_inline_enabled: bool, - render_diff_hunk_controls: RenderDiffHunkControlsFn, buffer_serialization: Option, show_selection_menu: Option, blame: Option>, @@ -1129,12 +1130,7 @@ pub struct Editor { addons: TypeIdHashMap>, registered_buffers: HashMap, load_diff_task: Option>>, - /// Whether we are temporarily displaying a diff other than git's - temporary_diff_override: bool, - /// Whether to render all diff hunks with the "unstaged" appearance, - /// regardless of whether they have a secondary hunk. Used by views whose - /// diffs aren't related to the git index (e.g. agent diffs). - render_diff_hunks_as_unstaged: bool, + diff_hunk_delegate: Option>, selection_mark_mode: bool, toggle_fold_multiple_buffers: Task<()>, _scroll_cursor_center_top_bottom_task: Task<()>, @@ -2285,7 +2281,6 @@ impl Editor { use_relative_line_numbers: None, disable_expand_excerpt_buttons: !full_mode, delegate_expand_excerpts: false, - delegate_stage_and_restore: false, delegate_open_excerpts: false, enable_lsp_data: full_mode, needs_initial_data_update: full_mode, @@ -2390,7 +2385,6 @@ impl Editor { show_git_blame_inline_delay_task: None, git_blame_inline_enabled: full_mode && ProjectSettings::get_global(cx).git.inline_blame.enabled, - render_diff_hunk_controls: Arc::new(render_diff_hunk_controls), buffer_serialization: is_minimap.not().then(|| { BufferSerialization::new( ProjectSettings::get_global(cx) @@ -2460,8 +2454,7 @@ impl Editor { serialize_folds: Task::ready(()), text_style_refinement: None, load_diff_task: load_uncommitted_diff, - temporary_diff_override: false, - render_diff_hunks_as_unstaged: false, + diff_hunk_delegate: None, minimap: None, change_list: ChangeList::new(), mode, @@ -11782,17 +11775,10 @@ pub enum EditorEvent { lines: u32, direction: ExpandExcerptDirection, }, - StageOrUnstageRequested { - stage: bool, - hunks: Vec, - }, OpenExcerptsRequested { selections_by_buffer: HashMap>, Option)>, split: bool, }, - RestoreRequested { - hunks: Vec, - }, /// Emitted when an underlying buffer changes, including edits made through another editor. BufferEdited, /// Emitted when this editor creates, undoes, or redoes an edit transaction. diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index e5836446cee..a82b3ea4488 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -4624,7 +4624,7 @@ impl EditorElement { window: &mut Window, cx: &mut App, ) -> (Vec, Vec<(DisplayRow, Bounds)>) { - let render_diff_hunk_controls = editor.read(cx).render_diff_hunk_controls.clone(); + let diff_hunk_delegate = editor.read(cx).diff_hunk_delegate(); let hovered_diff_hunk_row = editor.read(cx).hovered_diff_hunk_row; let sticky_top = text_hitbox.bounds.top() + sticky_header_height; @@ -4696,7 +4696,7 @@ impl EditorElement { sticky_top.min(max_y) }; - let mut element = render_diff_hunk_controls( + let mut element = diff_hunk_delegate.render_hunk_controls( display_row_range.start.0, status, multi_buffer_range.clone(), @@ -6548,8 +6548,11 @@ impl EditorElement { } fn diff_hunk_hollow(&self, status: DiffHunkStatus, cx: &mut App) -> bool { - let unstaged = - self.editor.read(cx).render_diff_hunks_as_unstaged || status.has_secondary_hunk(); + let unstaged = !self + .editor + .read(cx) + .diff_hunk_delegate() + .render_hunk_as_staged(&status, cx); let unstaged_hollow = matches!( ProjectSettings::get_global(cx).git.hunk_style, GitHunkStyleSetting::UnstagedHollow @@ -9296,7 +9299,7 @@ impl Element for EditorElement { }; let (diff_hunk_controls, diff_hunk_control_bounds) = - if is_read_only && !self.editor.read(cx).delegate_stage_and_restore { + if is_read_only && self.editor.read(cx).diff_hunk_delegate.is_none() { (vec![], vec![]) } else { self.layout_diff_hunk_controls( diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs index 6bb94a892dc..d9871fa2cb5 100644 --- a/crates/editor/src/git.rs +++ b/crates/editor/src/git.rs @@ -2,20 +2,242 @@ pub(super) mod blame; use super::*; use ::git::{Restore, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus}; -use buffer_diff::DiffHunkStatus; +use buffer_diff::{BufferDiff, DiffHunkStatus, DiffHunkStatusKind}; -pub type RenderDiffHunkControlsFn = Arc< - dyn Fn( - u32, - &DiffHunkStatus, - Range, - bool, - Pixels, - &Entity, - &mut Window, - &mut App, - ) -> AnyElement, ->; +#[derive(Clone)] +pub struct ResolvedDiffHunk { + pub buffer_range: Range, + pub diff_base_byte_range: Range, + pub status: DiffHunkStatus, +} + +#[derive(Clone)] +pub struct ResolvedDiffHunks { + pub diff: Entity, + pub buffer_id: BufferId, + pub buffer: Option>, + pub hunks: Vec, +} + +pub trait DiffHunkDelegate { + fn toggle( + &self, + hunks: Vec, + editor: &mut Editor, + window: &mut Window, + cx: &mut Context, + ); + + fn stage_or_unstage( + &self, + stage: bool, + hunks: Vec, + editor: &mut Editor, + window: &mut Window, + cx: &mut Context, + ); + + fn restore( + &self, + hunks: Vec, + editor: &mut Editor, + window: &mut Window, + cx: &mut Context, + ) { + if hunks.is_empty() || editor.read_only(cx) { + return; + } + self.stage_or_unstage(false, hunks.clone(), editor, window, cx); + editor.transact(window, cx, |editor, window, cx| { + editor.restore_diff_hunks(hunks, cx); + let selections = editor + .selections + .all::(&editor.display_snapshot(cx)); + editor.change_selections( + SelectionEffects::no_scroll(), + window, + cx, + |selections_state| { + selections_state.select(selections); + }, + ); + }); + } + + fn render_hunk_controls( + &self, + row: u32, + status: &DiffHunkStatus, + hunk_range: Range, + is_created_file: bool, + line_height: Pixels, + editor: &Entity, + window: &mut Window, + cx: &mut App, + ) -> AnyElement; + + fn render_hunk_as_staged(&self, status: &DiffHunkStatus, _cx: &App) -> bool { + !status.has_secondary_hunk() + } +} + +pub struct UncommittedDiffHunkDelegate; + +impl DiffHunkDelegate for UncommittedDiffHunkDelegate { + fn toggle( + &self, + hunks: Vec, + editor: &mut Editor, + window: &mut Window, + cx: &mut Context, + ) { + let stage = hunks + .iter() + .flat_map(|hunks| hunks.hunks.iter()) + .any(|hunk| hunk.status.has_secondary_hunk()); + self.stage_or_unstage(stage, hunks, editor, window, cx); + } + + fn stage_or_unstage( + &self, + stage: bool, + hunks: Vec, + editor: &mut Editor, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(project) = editor.project() else { + return; + }; + for hunks in hunks { + let Some(buffer) = hunks.buffer else { + continue; + }; + let ranges = hunks + .hunks + .into_iter() + .map(|hunk| hunk.buffer_range) + .collect::>(); + if ranges.is_empty() { + continue; + } + let secondary_diff = hunks.diff.read(cx).secondary_diff(); + project + .update(cx, |project, cx| { + if stage { + let Some(secondary_diff) = secondary_diff else { + return Err(anyhow::anyhow!("diff has no unstaged secondary")); + }; + project.stage_hunks(buffer, secondary_diff, ranges, cx) + } else { + project.unstage_uncommitted_hunks(buffer, hunks.diff, ranges, cx) + } + }) + .log_err(); + } + } + + fn render_hunk_controls( + &self, + row: u32, + status: &DiffHunkStatus, + hunk_range: Range, + is_created_file: bool, + line_height: Pixels, + editor: &Entity, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + render_diff_hunk_controls( + row, + status, + hunk_range, + is_created_file, + line_height, + editor, + window, + cx, + ) + } +} + +pub struct RestoreOnlyDiffHunkDelegate; + +impl DiffHunkDelegate for RestoreOnlyDiffHunkDelegate { + fn toggle( + &self, + _hunks: Vec, + _editor: &mut Editor, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn stage_or_unstage( + &self, + _stage: bool, + _hunks: Vec, + _editor: &mut Editor, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn render_hunk_controls( + &self, + _row: u32, + _status: &DiffHunkStatus, + _hunk_range: Range, + _is_created_file: bool, + _line_height: Pixels, + _editor: &Entity, + _window: &mut Window, + _cx: &mut App, + ) -> AnyElement { + gpui::Empty.into_any_element() + } +} + +pub struct RestoreOnlyUnstagedDiffHunkDelegate; + +impl DiffHunkDelegate for RestoreOnlyUnstagedDiffHunkDelegate { + fn toggle( + &self, + _hunks: Vec, + _editor: &mut Editor, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn stage_or_unstage( + &self, + _stage: bool, + _hunks: Vec, + _editor: &mut Editor, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn render_hunk_controls( + &self, + _row: u32, + _status: &DiffHunkStatus, + _hunk_range: Range, + _is_created_file: bool, + _line_height: Pixels, + _editor: &Entity, + _window: &mut Window, + _cx: &mut App, + ) -> AnyElement { + gpui::Empty.into_any_element() + } + + fn render_hunk_as_staged(&self, _status: &DiffHunkStatus, _cx: &App) -> bool { + false + } +} #[derive(Debug, Clone, PartialEq, Eq)] pub(super) enum DisplayDiffHunk { @@ -166,24 +388,115 @@ impl Editor { }) } - pub fn set_render_diff_hunk_controls( - &mut self, - render_diff_hunk_controls: RenderDiffHunkControlsFn, - cx: &mut Context, - ) { - self.render_diff_hunk_controls = render_diff_hunk_controls; - cx.notify(); + fn resolve_diff_hunks( + &self, + hunks: Vec, + cx: &App, + ) -> Vec { + let multibuffer = self.buffer().read(cx); + let chunk_by = hunks.into_iter().chunk_by(|hunk| hunk.buffer_id); + let mut resolved = Vec::new(); + + for (source_buffer_id, hunks) in &chunk_by { + let Some(diff) = multibuffer.diff_for(source_buffer_id) else { + continue; + }; + let diff_snapshot = diff.read(cx).snapshot(cx); + let main_buffer_id = diff_snapshot.buffer_id(); + let buffer = multibuffer.buffer(main_buffer_id).or_else(|| { + self.project + .as_ref() + .and_then(|project| project.read(cx).buffer_for_id(main_buffer_id, cx)) + }); + let mut resolved_hunks = Vec::new(); + + for hunk in hunks { + if hunk.buffer_id == main_buffer_id { + resolved_hunks.push(ResolvedDiffHunk { + buffer_range: hunk.buffer_range, + diff_base_byte_range: hunk.diff_base_byte_range.start.0 + ..hunk.diff_base_byte_range.end.0, + status: hunk.status, + }); + } else { + let diff_base_byte_range = + hunk.diff_base_byte_range.start.0..hunk.diff_base_byte_range.end.0; + let Some(hunk) = diff_snapshot + .hunks_intersecting_base_text_range( + diff_base_byte_range.clone(), + diff_snapshot.buffer_snapshot(), + ) + .find(|hunk| hunk.diff_base_byte_range == diff_base_byte_range) + else { + continue; + }; + let kind = if hunk.buffer_range.start == hunk.buffer_range.end { + DiffHunkStatusKind::Deleted + } else if hunk.diff_base_byte_range.is_empty() { + DiffHunkStatusKind::Added + } else { + DiffHunkStatusKind::Modified + }; + resolved_hunks.push(ResolvedDiffHunk { + buffer_range: hunk.buffer_range, + diff_base_byte_range: hunk.diff_base_byte_range, + status: DiffHunkStatus { + kind, + secondary: hunk.secondary_status, + }, + }); + } + } + + if !resolved_hunks.is_empty() { + resolved.push(ResolvedDiffHunks { + diff, + buffer_id: main_buffer_id, + buffer, + hunks: resolved_hunks, + }); + } + } + + resolved } - /// Make all diff hunks render with the "unstaged" appearance, regardless - /// of whether they have a secondary hunk. Intended for views whose diffs - /// aren't related to the git index (e.g. agent diffs). - pub fn set_render_diff_hunks_as_unstaged( + pub fn diff_hunk_delegate(&self) -> Arc { + self.diff_hunk_delegate + .clone() + .unwrap_or_else(|| Arc::new(UncommittedDiffHunkDelegate)) + } + + pub fn set_diff_hunk_delegate( &mut self, - render_as_unstaged: bool, + delegate: Option>, cx: &mut Context, ) { - self.render_diff_hunks_as_unstaged = render_as_unstaged; + let had_delegate = self.diff_hunk_delegate.is_some(); + let has_delegate = delegate.is_some(); + self.diff_hunk_delegate = delegate; + + if !had_delegate && has_delegate { + self.load_diff_task.take(); + } else if had_delegate && !has_delegate { + self.buffer.update(cx, |buffer, cx| { + buffer.set_all_diff_hunks_collapsed(cx); + }); + + if let Some(project) = self.project.clone() { + self.load_diff_task = Some( + update_uncommitted_diff_for_buffer( + cx.entity(), + &project, + self.buffer.read(cx).all_buffers(), + self.buffer.clone(), + cx, + ) + .shared(), + ); + } + } + cx.notify(); } @@ -265,33 +578,6 @@ impl Editor { cx.notify(); } - pub fn start_temporary_diff_override(&mut self) { - self.load_diff_task.take(); - self.temporary_diff_override = true; - } - - pub fn end_temporary_diff_override(&mut self, cx: &mut Context) { - self.temporary_diff_override = false; - self.render_diff_hunks_as_unstaged = false; - self.set_render_diff_hunk_controls(Arc::new(render_diff_hunk_controls), cx); - self.buffer.update(cx, |buffer, cx| { - buffer.set_all_diff_hunks_collapsed(cx); - }); - - if let Some(project) = self.project.clone() { - self.load_diff_task = Some( - update_uncommitted_diff_for_buffer( - cx.entity(), - &project, - self.buffer.read(cx).all_buffers(), - self.buffer.clone(), - cx, - ) - .shared(), - ); - } - } - /// Hides the inline blame popover element, in case it's already visible, or /// interrupts the task meant to show it, in case the task is running. /// @@ -764,31 +1050,48 @@ impl Editor { ); } - pub(super) fn restore_diff_hunks(&self, hunks: Vec, cx: &mut App) { - let mut revert_changes = HashMap::default(); - let chunk_by = hunks.into_iter().chunk_by(|hunk| hunk.buffer_id); - for (buffer_id, hunks) in &chunk_by { - let hunks = hunks.collect::>(); - for hunk in &hunks { - self.prepare_restore_change(&mut revert_changes, hunk, cx); - } - self.do_stage_or_unstage(false, buffer_id, hunks.into_iter(), cx); - } - if !revert_changes.is_empty() { - self.buffer().update(cx, |multi_buffer, cx| { - for (buffer_id, changes) in revert_changes { - if let Some(buffer) = multi_buffer.buffer(buffer_id) { - buffer.update(cx, |buffer, cx| { - buffer.edit( - changes - .into_iter() - .map(|(range, text)| (range, text.to_string())), - None, - cx, - ); - }); + pub(super) fn restore_diff_hunks( + &mut self, + hunks: Vec, + cx: &mut Context, + ) { + let mut revert_changes = Vec::new(); + for hunks in hunks { + let Some(buffer) = hunks.buffer else { + continue; + }; + let diff_snapshot = hunks.diff.read(cx).snapshot(cx); + let changes = hunks + .hunks + .into_iter() + .filter_map(|hunk| { + if hunk.diff_base_byte_range == (0..0) + && hunk.buffer_range.start.is_min() + && hunk.buffer_range.end.is_max() + { + return None; } - } + let original_text = diff_snapshot + .base_text() + .as_rope() + .slice(hunk.diff_base_byte_range.start..hunk.diff_base_byte_range.end); + Some((hunk.buffer_range, original_text)) + }) + .collect::>(); + if !changes.is_empty() { + revert_changes.push((buffer, changes)); + } + } + + for (buffer, changes) in revert_changes { + buffer.update(cx, |buffer, cx| { + buffer.edit( + changes + .into_iter() + .map(|(range, text)| (range, text.to_string())), + None, + cx, + ); }); } } @@ -1421,18 +1724,25 @@ impl Editor { pub(super) fn toggle_staged_selected_diff_hunks( &mut self, _: &::git::ToggleStaged, - _: &mut Window, + window: &mut Window, cx: &mut Context, ) { - let snapshot = self.buffer.read(cx).snapshot(cx); let ranges: Vec<_> = self .selections .disjoint_anchors() .iter() .map(|s| s.range()) .collect(); - let stage = self.has_stageable_diff_hunks_in_ranges(&ranges, &snapshot); - self.stage_or_unstage_diff_hunks(stage, ranges, cx); + let task = self.save_buffers_for_ranges_if_needed(&ranges, cx); + cx.spawn_in(window, async move |this, cx| { + task.await?; + this.update_in(cx, |this, window, cx| { + let snapshot = this.buffer.read(cx).snapshot(cx); + let hunks = this.diff_hunks_in_ranges(&ranges, &snapshot).collect(); + this.apply_toggle(hunks, window, cx); + }) + }) + .detach_and_log_err(cx); } pub(super) fn stage_and_next( @@ -1453,42 +1763,47 @@ impl Editor { self.do_stage_or_unstage_and_next(false, window, cx); } - pub(super) fn do_stage_or_unstage( - &self, + pub fn apply_toggle( + &mut self, + hunks: Vec, + window: &mut Window, + cx: &mut Context, + ) { + let hunks = self.resolve_diff_hunks(hunks, cx); + if hunks.is_empty() { + return; + } + let delegate = self.diff_hunk_delegate(); + delegate.toggle(hunks, self, window, cx); + } + + pub fn apply_stage_or_unstage( + &mut self, stage: bool, - buffer_id: BufferId, - hunks: impl Iterator, - cx: &mut App, - ) -> Option<()> { - let project = self.project()?; - let buffer = project.read(cx).buffer_for_id(buffer_id, cx)?; - let diff = self.buffer.read(cx).diff_for(buffer_id)?; - let buffer_snapshot = buffer.read(cx).snapshot(); - let file_exists = buffer_snapshot - .file() - .is_some_and(|file| file.disk_state().exists()); - diff.update(cx, |diff, cx| { - diff.stage_or_unstage_hunks( - stage, - &hunks - .map(|hunk| buffer_diff::DiffHunk { - buffer_range: hunk.buffer_range, - // We don't need to pass in word diffs here because they're only used for rendering and - // this function changes internal state - base_word_diffs: Vec::default(), - buffer_word_diffs: Vec::default(), - diff_base_byte_range: hunk.diff_base_byte_range.start.0 - ..hunk.diff_base_byte_range.end.0, - secondary_status: hunk.status.secondary, - range: Point::zero()..Point::zero(), // unused - }) - .collect::>(), - &buffer_snapshot, - file_exists, - cx, - ) - }); - None + hunks: Vec, + window: &mut Window, + cx: &mut Context, + ) { + let hunks = self.resolve_diff_hunks(hunks, cx); + if hunks.is_empty() { + return; + } + let delegate = self.diff_hunk_delegate(); + delegate.stage_or_unstage(stage, hunks, self, window, cx); + } + + pub fn apply_restore( + &mut self, + hunks: Vec, + window: &mut Window, + cx: &mut Context, + ) { + let hunks = self.resolve_diff_hunks(hunks, cx); + if hunks.is_empty() { + return; + } + let delegate = self.diff_hunk_delegate(); + delegate.restore(hunks, self, window, cx); } pub(super) fn clear_expanded_diff_hunks(&mut self, cx: &mut Context) -> bool { @@ -1776,31 +2091,20 @@ impl Editor { } } - fn stage_or_unstage_diff_hunks( + pub fn stage_or_unstage_diff_hunks( &mut self, stage: bool, ranges: Vec>, + window: &mut Window, cx: &mut Context, ) { - if self.delegate_stage_and_restore { - let snapshot = self.buffer.read(cx).snapshot(cx); - let hunks: Vec<_> = self.diff_hunks_in_ranges(&ranges, &snapshot).collect(); - if !hunks.is_empty() { - cx.emit(EditorEvent::StageOrUnstageRequested { stage, hunks }); - } - return; - } let task = self.save_buffers_for_ranges_if_needed(&ranges, cx); - cx.spawn(async move |this, cx| { + cx.spawn_in(window, async move |this, cx| { task.await?; - this.update(cx, |this, cx| { + this.update_in(cx, |this, window, cx| { let snapshot = this.buffer.read(cx).snapshot(cx); - let chunk_by = this - .diff_hunks_in_ranges(&ranges, &snapshot) - .chunk_by(|hunk| hunk.buffer_id); - for (buffer_id, hunks) in &chunk_by { - this.do_stage_or_unstage(stage, buffer_id, hunks, cx); - } + let hunks = this.diff_hunks_in_ranges(&ranges, &snapshot).collect(); + this.apply_stage_or_unstage(stage, hunks, window, cx); }) }) .detach_and_log_err(cx); @@ -1847,66 +2151,8 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - if self.delegate_stage_and_restore { - let hunks = self.snapshot(window, cx).hunks_for_ranges(ranges); - if !hunks.is_empty() { - cx.emit(EditorEvent::RestoreRequested { hunks }); - } - return; - } let hunks = self.snapshot(window, cx).hunks_for_ranges(ranges); - self.transact(window, cx, |editor, window, cx| { - editor.restore_diff_hunks(hunks, cx); - let selections = editor - .selections - .all::(&editor.display_snapshot(cx)); - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select(selections); - }); - }); - } - - fn has_stageable_diff_hunks_in_ranges( - &self, - ranges: &[Range], - snapshot: &MultiBufferSnapshot, - ) -> bool { - let mut hunks = self.diff_hunks_in_ranges(ranges, snapshot); - hunks.any(|hunk| hunk.status().has_secondary_hunk()) - } - - fn prepare_restore_change( - &self, - revert_changes: &mut HashMap, Rope)>>, - hunk: &MultiBufferDiffHunk, - cx: &mut App, - ) -> Option<()> { - if hunk.is_created_file() { - return None; - } - let multi_buffer = self.buffer.read(cx); - let multi_buffer_snapshot = multi_buffer.snapshot(cx); - let diff_snapshot = multi_buffer_snapshot.diff_for_buffer_id(hunk.buffer_id)?; - let original_text = diff_snapshot - .base_text() - .as_rope() - .slice(hunk.diff_base_byte_range.start.0..hunk.diff_base_byte_range.end.0); - let buffer = multi_buffer.buffer(hunk.buffer_id)?; - let buffer = buffer.read(cx); - let buffer_snapshot = buffer.snapshot(); - let buffer_revert_changes = revert_changes.entry(buffer.remote_id()).or_default(); - if let Err(i) = buffer_revert_changes.binary_search_by(|probe| { - probe - .0 - .start - .cmp(&hunk.buffer_range.start, &buffer_snapshot) - .then(probe.0.end.cmp(&hunk.buffer_range.end, &buffer_snapshot)) - }) { - buffer_revert_changes.insert(i, (hunk.buffer_range.clone(), original_text)); - Some(()) - } else { - None - } + self.apply_restore(hunks, window, cx); } fn save_buffers_for_ranges_if_needed( @@ -1949,11 +2195,11 @@ impl Editor { let ranges = self.selections.disjoint_anchor_ranges().collect::>(); if ranges.iter().any(|range| range.start != range.end) { - self.stage_or_unstage_diff_hunks(stage, ranges, cx); + self.stage_or_unstage_diff_hunks(stage, ranges, window, cx); return; } - self.stage_or_unstage_diff_hunks(stage, ranges, cx); + self.stage_or_unstage_diff_hunks(stage, ranges, window, cx); let all_diff_hunks_expanded = self.buffer().read(cx).all_diff_hunks_expanded(); let wrap_around = !all_diff_hunks_expanded; @@ -2678,7 +2924,7 @@ pub fn set_blame_renderer(renderer: impl BlameRenderer + 'static, cx: &mut App) cx.set_global(GlobalBlameRenderer(Arc::new(renderer))); } -pub(super) fn render_diff_hunk_controls( +pub fn render_diff_hunk_controls( row: u32, status: &DiffHunkStatus, hunk_range: Range, @@ -2723,11 +2969,12 @@ pub(super) fn render_diff_hunk_controls( }) .on_click({ let editor = editor.clone(); - move |_event, _window, cx| { + move |_event, window, cx| { editor.update(cx, |editor, cx| { editor.stage_or_unstage_diff_hunks( true, vec![hunk_range.start..hunk_range.start], + window, cx, ); }); @@ -2749,11 +2996,12 @@ pub(super) fn render_diff_hunk_controls( }) .on_click({ let editor = editor.clone(); - move |_event, _window, cx| { + move |_event, window, cx| { editor.update(cx, |editor, cx| { editor.stage_or_unstage_diff_hunks( false, vec![hunk_range.start..hunk_range.start], + window, cx, ); }); @@ -2880,7 +3128,7 @@ pub(super) fn update_uncommitted_diff_for_buffer( }); cx.spawn(async move |cx| { let diffs = future::join_all(tasks).await; - if editor.read_with(cx, |editor, _cx| editor.temporary_diff_override) { + if editor.read_with(cx, |editor, _cx| editor.diff_hunk_delegate.is_some()) { return; } diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index dd34391e195..93a6f96a443 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -3,19 +3,19 @@ use std::{ sync::Arc, }; -use buffer_diff::{BufferDiff, BufferDiffSnapshot}; +use buffer_diff::{BufferDiff, BufferDiffSnapshot, DiffHunkStatus}; use collections::HashMap; use fs::Fs; use gpui::{ - Action, Entity, EventEmitter, Focusable, Font, Pixels, Subscription, WeakEntity, canvas, - prelude::*, + Action, AnyElement, Entity, EventEmitter, Focusable, Font, Pixels, Subscription, WeakEntity, + canvas, prelude::*, }; -use itertools::Itertools; + use language::{Buffer, Capability, HighlightedText}; use multi_buffer::{ Anchor, AnchorRangeExt as _, BufferOffset, ExcerptRange, ExpandExcerptDirection, MultiBuffer, - MultiBufferDiffHunk, MultiBufferPoint, MultiBufferSnapshot, PathKey, + MultiBufferPoint, MultiBufferSnapshot, PathKey, }; use project::Project; use rope::Point; @@ -23,6 +23,7 @@ use settings::{DiffViewStyle, SeedQuerySetting, Settings, SettingsStore, update_ use text::{Bias, BufferId, OffsetRangeExt as _, Patch, ToPoint as _}; use ui::{Toggleable as _, Tooltip, prelude::*, render_modifiers}; +use util::ResultExt as _; use crate::{ display_map::CompanionExcerptPatch, @@ -36,7 +37,8 @@ use workspace::{ }; use crate::{ - Autoscroll, Editor, EditorEvent, EditorSettings, RenderDiffHunkControlsFn, ToggleSoftWrap, + Autoscroll, DiffHunkDelegate, Editor, EditorEvent, EditorSettings, ResolvedDiffHunks, + ToggleSoftWrap, UncommittedDiffHunkDelegate, actions::{DisableBreakpoint, EditLogBreakpoint, EnableBreakpoint, ToggleBreakpoint}, display_map::Companion, }; @@ -149,32 +151,97 @@ fn translate_lhs_selections_to_rhs( translated } -fn translate_lhs_hunks_to_rhs( - lhs_hunks: &[MultiBufferDiffHunk], - splittable: &SplittableEditor, - cx: &App, -) -> Vec { - let Some(lhs) = &splittable.lhs else { - return vec![]; - }; - let lhs_snapshot = lhs.multibuffer.read(cx).snapshot(cx); - let rhs_snapshot = splittable.rhs_multibuffer.read(cx).snapshot(cx); - let rhs_hunks: Vec = rhs_snapshot.diff_hunks().collect(); +struct SplitLhsDiffHunkDelegate { + splittable: WeakEntity, +} - let mut translated = Vec::new(); - for lhs_hunk in lhs_hunks { - let Some(diff) = lhs_snapshot.diff_for_buffer_id(lhs_hunk.buffer_id) else { - continue; - }; - let rhs_buffer_id = diff.buffer_id(); - if let Some(rhs_hunk) = rhs_hunks.iter().find(|rhs_hunk| { - rhs_hunk.buffer_id == rhs_buffer_id - && rhs_hunk.diff_base_byte_range == lhs_hunk.diff_base_byte_range - }) { - translated.push(rhs_hunk.clone()); - } +impl DiffHunkDelegate for SplitLhsDiffHunkDelegate { + fn toggle( + &self, + hunks: Vec, + _editor: &mut Editor, + window: &mut Window, + cx: &mut Context, + ) { + self.splittable + .update(cx, |splittable, cx| { + splittable.rhs_editor.update(cx, |editor, cx| { + let delegate = editor.diff_hunk_delegate(); + delegate.toggle(hunks, editor, window, cx); + }); + }) + .log_err(); + } + + fn stage_or_unstage( + &self, + stage: bool, + hunks: Vec, + _editor: &mut Editor, + window: &mut Window, + cx: &mut Context, + ) { + self.splittable + .update(cx, |splittable, cx| { + splittable.rhs_editor.update(cx, |editor, cx| { + let delegate = editor.diff_hunk_delegate(); + delegate.stage_or_unstage(stage, hunks, editor, window, cx); + }); + }) + .log_err(); + } + + fn restore( + &self, + hunks: Vec, + _editor: &mut Editor, + window: &mut Window, + cx: &mut Context, + ) { + self.splittable + .update(cx, |splittable, cx| { + splittable.rhs_editor.update(cx, |editor, cx| { + let delegate = editor.diff_hunk_delegate(); + delegate.restore(hunks, editor, window, cx); + }); + }) + .log_err(); + } + + fn render_hunk_controls( + &self, + row: u32, + status: &DiffHunkStatus, + hunk_range: Range, + is_created_file: bool, + line_height: Pixels, + editor: &Entity, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + let Some(splittable) = self.splittable.upgrade() else { + return gpui::Empty.into_any_element(); + }; + let delegate = splittable.read(cx).rhs_editor.read(cx).diff_hunk_delegate(); + delegate.render_hunk_controls( + row, + status, + hunk_range, + is_created_file, + line_height, + editor, + window, + cx, + ) + } + + fn render_hunk_as_staged(&self, status: &DiffHunkStatus, cx: &App) -> bool { + let Some(splittable) = self.splittable.upgrade() else { + return false; + }; + let delegate = splittable.read(cx).rhs_editor.read(cx).diff_hunk_delegate(); + delegate.render_hunk_as_staged(status, cx) } - translated } fn patches_for_range( @@ -569,28 +636,13 @@ impl SplittableEditor { self.lhs.is_some() } - pub fn set_render_diff_hunk_controls( + pub fn set_diff_hunk_delegate( &self, - render_diff_hunk_controls: RenderDiffHunkControlsFn, + delegate: Option>, cx: &mut Context, ) { - self.update_editors(cx, |editor, cx| { - editor.set_render_diff_hunk_controls(render_diff_hunk_controls.clone(), cx); - }); - } - - pub fn disable_diff_hunk_controls(&self, cx: &mut Context) { - let empty_controls = Arc::new(|_, _: &_, _, _, _, _: &_, _: &mut _, _: &mut _| { - gpui::Empty.into_any_element() - }); - self.update_editors(cx, |editor, cx| { - editor.set_render_diff_hunk_controls(empty_controls.clone(), cx); - }); - } - - pub fn set_render_diff_hunks_as_unstaged(&self, cx: &mut Context) { - self.update_editors(cx, |editor, cx| { - editor.set_render_diff_hunks_as_unstaged(true, cx); + self.rhs_editor.update(cx, |editor, cx| { + editor.set_diff_hunk_delegate(delegate, cx); }); } @@ -631,7 +683,7 @@ impl SplittableEditor { editor.disable_inline_diagnostics(); editor.disable_mouse_wheel_zoom(); editor.set_minimap_visibility(crate::MinimapVisibility::Disabled, window, cx); - editor.start_temporary_diff_override(); + editor.set_diff_hunk_delegate(Some(Arc::new(UncommittedDiffHunkDelegate)), cx); editor }); // TODO(split-diff) we might want to tag editor events with whether they came from rhs/lhs @@ -729,15 +781,16 @@ impl SplittableEditor { multibuffer }); - let render_diff_hunk_controls = self.rhs_editor.read(cx).render_diff_hunk_controls.clone(); - let render_diff_hunks_as_unstaged = self.rhs_editor.read(cx).render_diff_hunks_as_unstaged; + let splittable = cx.weak_entity(); let lhs_editor = cx.new(|cx| { let mut editor = Editor::for_multibuffer(lhs_multibuffer.clone(), Some(project.clone()), window, cx); - editor.set_render_diff_hunks_as_unstaged(render_diff_hunks_as_unstaged, cx); editor.set_number_deleted_lines(true, cx); editor.set_delegate_expand_excerpts(true); - editor.set_delegate_stage_and_restore(true); + editor.set_diff_hunk_delegate( + Some(Arc::new(SplitLhsDiffHunkDelegate { splittable })), + cx, + ); editor.set_delegate_open_excerpts(true); editor.set_show_vertical_scrollbar(false, cx); editor.disable_lsp_data(); @@ -748,10 +801,6 @@ impl SplittableEditor { editor }); - lhs_editor.update(cx, |editor, cx| { - editor.set_render_diff_hunk_controls(render_diff_hunk_controls, cx); - }); - let mut subscriptions = vec![cx.subscribe_in( &lhs_editor, window, @@ -782,30 +831,7 @@ impl SplittableEditor { this.expand_excerpts(rhs_anchors.into_iter(), *lines, *direction, cx); } } - EditorEvent::StageOrUnstageRequested { stage, hunks } => { - if this.lhs.is_some() { - let translated = translate_lhs_hunks_to_rhs(hunks, this, cx); - if !translated.is_empty() { - let stage = *stage; - this.rhs_editor.update(cx, |editor, cx| { - let chunk_by = translated.into_iter().chunk_by(|h| h.buffer_id); - for (buffer_id, hunks) in &chunk_by { - editor.do_stage_or_unstage(stage, buffer_id, hunks, cx); - } - }); - } - } - } - EditorEvent::RestoreRequested { hunks } => { - if this.lhs.is_some() { - let translated = translate_lhs_hunks_to_rhs(hunks, this, cx); - if !translated.is_empty() { - this.rhs_editor.update(cx, |editor, cx| { - editor.restore_diff_hunks(translated, cx); - }); - } - } - } + EditorEvent::OpenExcerptsRequested { selections_by_buffer, split, diff --git a/crates/git_ui/src/branch_diff.rs b/crates/git_ui/src/branch_diff.rs new file mode 100644 index 00000000000..068d8e9af19 --- /dev/null +++ b/crates/git_ui/src/branch_diff.rs @@ -0,0 +1,1199 @@ +use crate::{ + branch_picker, + diff_multibuffer::DiffMultibuffer, + project_diff::{ + self, CompareWithBranch, DeployBranchDiff, ReviewDiff, render_send_review_to_agent_button, + }, +}; +use agent_settings::AgentSettings; +use anyhow::{Context as _, Result, anyhow}; +use editor::{ + Addon, Editor, EditorEvent, RestoreOnlyDiffHunkDelegate, SplittableEditor, + actions::SendReviewToAgent, +}; +use git::{repository::DiffType, status::FileStatus}; +use gpui::{ + Action, AnyElement, App, AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, Render, + SharedString, Subscription, Task, WeakEntity, +}; +use language::{BufferId, Capability}; +use project::{ + Project, ProjectPath, + git_store::{ + Repository, + diff_buffer_list::{self, DiffBase}, + }, +}; +use settings::Settings; +use std::{ + any::{Any, TypeId}, + sync::Arc, +}; +use ui::{DiffStat, Divider, PopoverMenu, Tooltip, prelude::*}; +use workspace::{ + ItemHandle, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, + ToolbarItemView, Workspace, + item::{Item, ItemEvent, SaveOptions, TabContentParams}, + notifications::NotifyTaskExt, + searchable::SearchableItemHandle, +}; +use zed_actions::agent::ReviewBranchDiff; + +/// The workspace item for a branch (merge-base) diff: "Changes since {branch}". +/// It wraps a single [`DiffMultibuffer`] over [`DiffBase::Merge`] and delegates +/// the [`Item`] surface to it. The merge base can be changed in place via the +/// [`BranchDiffToolbar`]'s branch picker, which reloads without reconfiguring +/// the editor (the merge styling is identical for every base ref). +pub struct BranchDiff { + diff: Entity, + project: Entity, + workspace: WeakEntity, + _diff_event_subscription: Subscription, +} + +struct BranchDiffAddon { + branch_diff: Entity, +} + +impl Addon for BranchDiffAddon { + fn to_any(&self) -> &dyn std::any::Any { + self + } + + fn override_status_for_buffer_id(&self, buffer_id: BufferId, cx: &App) -> Option { + self.branch_diff + .read(cx) + .status_for_buffer_id(buffer_id, cx) + } +} + +impl BranchDiff { + pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) { + workspace.register_action(Self::deploy_branch_diff); + workspace.register_action(Self::compare_with_branch); + workspace::register_serializable_item::(cx); + } + + fn deploy_branch_diff( + workspace: &mut Workspace, + _: &DeployBranchDiff, + window: &mut Window, + cx: &mut Context, + ) { + telemetry::event!("Git Branch Diff Opened"); + let project = workspace.project().clone(); + let Some(intended_repo) = project.read(cx).active_repository(cx) else { + let workspace = cx.entity().downgrade(); + window + .spawn(cx, async |_cx| { + let result: Result<()> = Err(anyhow!("No active repository")); + result + }) + .detach_and_notify_err(workspace, window, cx); + return; + }; + + let default_branch = intended_repo.update(cx, |repo, _| repo.default_branch(true)); + let workspace = cx.entity(); + let workspace_weak = workspace.downgrade(); + window + .spawn(cx, async move |cx| { + let base_ref = default_branch + .await?? + .context("Could not determine default branch")?; + + workspace.update_in(cx, |workspace, window, cx| { + Self::deploy_branch_diff_with_base_ref( + workspace, + project, + intended_repo, + base_ref, + window, + cx, + ); + })?; + + anyhow::Ok(()) + }) + .detach_and_notify_err(workspace_weak, window, cx); + } + + fn compare_with_branch( + workspace: &mut Workspace, + _: &CompareWithBranch, + window: &mut Window, + cx: &mut Context, + ) { + let project = workspace.project().clone(); + let Some(repository) = project.read(cx).active_repository(cx) else { + let workspace = cx.entity().downgrade(); + window + .spawn(cx, async |_cx| { + let result: Result<()> = Err(anyhow!("No active repository")); + result + }) + .detach_and_notify_err(workspace, window, cx); + return; + }; + let selected_branch = workspace.active_item_as::(cx).and_then(|item| { + match item.read(cx).diff_base(cx) { + DiffBase::Merge { base_ref } => Some(base_ref.clone()), + DiffBase::Head | DiffBase::Index | DiffBase::Staged => None, + } + }); + let workspace_handle = workspace.weak_handle(); + let on_select = Arc::new({ + let repository = repository.clone(); + let workspace = workspace_handle.clone(); + move |branch: git::repository::Branch, window: &mut Window, cx: &mut App| { + let base_ref: SharedString = branch.name().to_owned().into(); + workspace + .update(cx, |workspace, cx| { + Self::deploy_branch_diff_with_base_ref( + workspace, + project.clone(), + repository.clone(), + base_ref, + window, + cx, + ); + }) + .ok(); + } + }); + + workspace.toggle_modal(window, cx, |window, cx| { + branch_picker::select_modal( + workspace_handle, + Some(repository), + selected_branch, + on_select, + window, + cx, + ) + }); + } + + fn deploy_branch_diff_with_base_ref( + workspace: &mut Workspace, + project: Entity, + intended_repo: Entity, + base_ref: SharedString, + window: &mut Window, + cx: &mut Context, + ) { + let existing = workspace.items_of_type::(cx).find(|item| { + let item = item.read(cx); + matches!( + item.diff_base(cx), + DiffBase::Merge { base_ref: existing_base_ref } if existing_base_ref == &base_ref + ) + }); + if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + + let needs_switch = existing.read(cx).repo(cx).map_or(true, |current| { + current.read(cx).id != intended_repo.read(cx).id + }); + + if needs_switch { + existing.update(cx, |branch_diff, cx| { + branch_diff.set_repo(Some(intended_repo), cx); + }); + } + + return; + } + + let workspace = cx.entity(); + let workspace_weak = workspace.downgrade(); + window + .spawn(cx, async move |cx| { + let this = cx + .update(|window, cx| { + Self::new_with_branch_base( + project, + workspace.clone(), + base_ref, + intended_repo, + window, + cx, + ) + })? + .await?; + workspace + .update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(this), None, true, window, cx); + }) + .ok(); + anyhow::Ok(()) + }) + .detach_and_notify_err(workspace_weak, window, cx); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn new_with_default_branch( + project: Entity, + workspace: Entity, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + let Some(repo) = project.read(cx).git_store().read(cx).active_repository() else { + return Task::ready(Err(anyhow!("No active repository"))); + }; + let main_branch = repo.update(cx, |repo, _| repo.default_branch(true)); + window.spawn(cx, async move |cx| { + let base_ref = main_branch + .await?? + .context("Could not determine default branch")?; + cx.update(|window, cx| { + cx.new(|cx| { + Self::new_with_base_ref(project, workspace, base_ref, Some(repo), window, cx) + }) + }) + }) + } + + pub(crate) fn new_with_branch_base( + project: Entity, + workspace: Entity, + base_ref: SharedString, + repo: Entity, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + window.spawn(cx, async move |cx| { + cx.update(|window, cx| { + cx.new(|cx| { + Self::new_with_base_ref(project, workspace, base_ref, Some(repo), window, cx) + }) + }) + }) + } + + pub(crate) fn new_with_base_ref( + project: Entity, + workspace: Entity, + base_ref: SharedString, + repo: Option>, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let branch_diff = cx.new(|cx| { + let mut branch_diff = diff_buffer_list::DiffBufferList::new( + DiffBase::Merge { base_ref }, + project.clone(), + window, + cx, + ); + if repo.is_some() { + branch_diff.set_repo(repo, cx); + } + branch_diff + }); + let branch_diff_for_addon = branch_diff.clone(); + let diff = cx.new(|cx| { + DiffMultibuffer::new( + branch_diff, + Capability::ReadWrite, + "No changes", + move |editor, cx| { + editor.set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyDiffHunkDelegate)), cx); + editor.rhs_editor().update(cx, move |rhs_editor, _cx| { + rhs_editor.set_read_only(false); + rhs_editor.register_addon(BranchDiffAddon { + branch_diff: branch_diff_for_addon, + }); + }); + }, + project.clone(), + workspace.clone(), + window, + cx, + ) + }); + Self::from_diff(diff, project, workspace, cx) + } + + fn from_diff( + diff: Entity, + project: Entity, + workspace: Entity, + cx: &mut Context, + ) -> Self { + let diff_event_subscription = cx.subscribe(&diff, |_, _, event: &EditorEvent, cx| { + cx.emit(event.clone()) + }); + Self { + diff, + project, + workspace: workspace.downgrade(), + _diff_event_subscription: diff_event_subscription, + } + } + + pub(crate) fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase { + self.diff.read(cx).diff_base(cx) + } + + pub(crate) fn repo(&self, cx: &App) -> Option> { + self.diff.read(cx).repo(cx) + } + + pub(crate) fn set_repo(&mut self, repo: Option>, cx: &mut Context) { + self.diff.update(cx, |diff, cx| diff.set_repo(repo, cx)); + } + + fn set_merge_base(&mut self, base_ref: SharedString, cx: &mut Context) { + self.diff.update(cx, |diff, cx| { + diff.branch_diff().update(cx, |branch_diff, cx| { + branch_diff.set_diff_base(DiffBase::Merge { base_ref }, cx); + }); + }); + } + + fn review_diff(&mut self, _: &ReviewDiff, window: &mut Window, cx: &mut Context) { + let DiffBase::Merge { base_ref } = self.diff_base(cx).clone() else { + return; + }; + let Some(repo) = self.repo(cx) else { + return; + }; + + let diff_receiver = repo.update(cx, |repo, cx| { + repo.diff( + DiffType::MergeBase { + base_ref: base_ref.clone(), + }, + cx, + ) + }); + + let workspace = self.workspace.clone(); + window + .spawn(cx, { + let workspace = workspace.clone(); + async move |cx| { + let diff_text = diff_receiver.await??; + + if let Some(workspace) = workspace.upgrade() { + workspace.update_in(cx, |_workspace, window, cx| { + window.dispatch_action( + ReviewBranchDiff { + diff_text: diff_text.into(), + base_ref, + } + .boxed_clone(), + cx, + ); + })?; + } + + anyhow::Ok(()) + } + }) + .detach_and_notify_err(workspace, window, cx); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn editor(&self, cx: &App) -> Entity { + self.diff.read(cx).editor().clone() + } +} + +impl EventEmitter for BranchDiff {} + +impl Focusable for BranchDiff { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.diff.read(cx).focus_handle(cx) + } +} + +impl Item for BranchDiff { + type Event = EditorEvent; + + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(Icon::new(IconName::GitBranch).color(Color::Muted)) + } + + fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) { + Editor::to_item_events(event, f) + } + + fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { + self.diff + .update(cx, |diff, cx| diff.deactivated(window, cx)); + } + + fn navigate( + &mut self, + data: Arc, + window: &mut Window, + cx: &mut Context, + ) -> bool { + self.diff + .update(cx, |diff, cx| diff.navigate(data, window, cx)) + } + + fn tab_tooltip_text(&self, cx: &App) -> Option { + Some(self.tab_content_text(0, cx)) + } + + fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { + Label::new(self.tab_content_text(0, cx)) + .color(if params.selected { + Color::Default + } else { + Color::Muted + }) + .into_any_element() + } + + fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString { + match self.diff_base(cx) { + DiffBase::Merge { base_ref } => format!("Changes since {}", base_ref).into(), + DiffBase::Head | DiffBase::Index | DiffBase::Staged => "Changes".into(), + } + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("Branch Diff Opened") + } + + fn as_searchable(&self, _: &Entity, cx: &App) -> Option> { + Some(Box::new(self.diff.read(cx).editor().clone())) + } + + fn for_each_project_item( + &self, + cx: &App, + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), + ) { + self.diff.read(cx).for_each_project_item(cx, f); + } + + fn active_project_path(&self, cx: &App) -> Option { + self.diff.read(cx).active_project_path(cx) + } + + fn set_nav_history( + &mut self, + nav_history: ItemNavHistory, + _: &mut Window, + cx: &mut Context, + ) { + self.diff + .update(cx, |diff, cx| diff.set_nav_history(nav_history, cx)); + } + + fn can_split(&self) -> bool { + true + } + + fn clone_on_split( + &self, + _workspace_id: Option, + window: &mut Window, + cx: &mut Context, + ) -> Task>> + where + Self: Sized, + { + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(None); + }; + let DiffBase::Merge { base_ref } = self.diff_base(cx).clone() else { + return Task::ready(None); + }; + let repo = self.repo(cx); + let project = self.project.clone(); + Task::ready(Some(cx.new(|cx| { + Self::new_with_base_ref(project, workspace, base_ref, repo, window, cx) + }))) + } + + fn is_dirty(&self, cx: &App) -> bool { + self.diff.read(cx).is_dirty(cx) + } + + fn has_conflict(&self, cx: &App) -> bool { + self.diff.read(cx).has_conflict(cx) + } + + fn can_save(&self, _cx: &App) -> bool { + true + } + + fn save( + &mut self, + options: SaveOptions, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.diff + .update(cx, |diff, cx| diff.save(options, project, window, cx)) + } + + fn save_as( + &mut self, + _: Entity, + _: ProjectPath, + _: &mut Window, + _: &mut Context, + ) -> Task> { + unreachable!() + } + + fn reload( + &mut self, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.diff + .update(cx, |diff, cx| diff.reload(project, window, cx)) + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a Entity, + cx: &'a App, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.clone().into()) + } else if type_id == TypeId::of::() { + Some(self.diff.clone().into()) + } else if type_id == TypeId::of::() { + Some( + self.diff + .read(cx) + .editor() + .read(cx) + .rhs_editor() + .clone() + .into(), + ) + } else if type_id == TypeId::of::() { + Some(self.diff.read(cx).editor().clone().into()) + } else if type_id == TypeId::of::() { + Some(self.diff.read(cx).branch_diff().clone().into()) + } else { + None + } + } + + fn added_to_workspace( + &mut self, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + self.diff.update(cx, |diff, cx| { + diff.added_to_workspace(workspace, window, cx) + }); + } +} + +impl Render for BranchDiff { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .size_full() + .on_action(cx.listener(Self::review_diff)) + .child(self.diff.clone()) + } +} + +impl SerializableItem for BranchDiff { + fn serialized_item_kind() -> &'static str { + "BranchDiff" + } + + fn cleanup( + _: workspace::WorkspaceId, + _: Vec, + _: &mut Window, + _: &mut App, + ) -> Task> { + Task::ready(Ok(())) + } + + fn deserialize( + project: Entity, + workspace: WeakEntity, + workspace_id: workspace::WorkspaceId, + item_id: workspace::ItemId, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + let db = project_diff::persistence::ProjectDiffDb::global(cx); + window.spawn(cx, async move |cx| { + let diff_base = db.get_project_diff_base(item_id, workspace_id)?; + let DiffBase::Merge { base_ref } = diff_base else { + anyhow::bail!("expected a merge base for a branch diff"); + }; + let workspace = workspace.upgrade().context("workspace gone")?; + cx.update(|window, cx| { + cx.new(|cx| Self::new_with_base_ref(project, workspace, base_ref, None, window, cx)) + }) + }) + } + + fn serialize( + &mut self, + workspace: &mut Workspace, + item_id: workspace::ItemId, + _closing: bool, + _window: &mut Window, + cx: &mut Context, + ) -> Option>> { + let workspace_id = workspace.database_id()?; + let DiffBase::Merge { base_ref } = self.diff_base(cx).clone() else { + return None; + }; + let diff_base = DiffBase::Merge { base_ref }; + let db = project_diff::persistence::ProjectDiffDb::global(cx); + Some(cx.background_spawn(async move { + db.save_project_diff_base(item_id, workspace_id, diff_base) + .await + })) + } + + fn should_serialize(&self, _: &Self::Event) -> bool { + false + } +} + +pub struct BranchDiffToolbar { + branch_diff: Option>, +} + +impl BranchDiffToolbar { + pub fn new(_cx: &mut Context) -> Self { + Self { branch_diff: None } + } + + fn branch_diff(&self, _: &App) -> Option> { + self.branch_diff.as_ref()?.upgrade() + } + + fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context) { + if let Some(branch_diff) = self.branch_diff(cx) { + branch_diff.focus_handle(cx).focus(window, cx); + } + let action = action.boxed_clone(); + cx.defer(move |cx| { + cx.dispatch_action(action.as_ref()); + }) + } +} + +impl EventEmitter for BranchDiffToolbar {} + +impl ToolbarItemView for BranchDiffToolbar { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _: &mut Window, + cx: &mut Context, + ) -> ToolbarItemLocation { + self.branch_diff = active_pane_item + .and_then(|item| item.act_as::(cx)) + .map(|entity| entity.downgrade()); + if self.branch_diff.is_some() { + ToolbarItemLocation::PrimaryRight + } else { + ToolbarItemLocation::Hidden + } + } + + fn pane_focus_update( + &mut self, + _pane_focused: bool, + _window: &mut Window, + _cx: &mut Context, + ) { + } +} + +impl Render for BranchDiffToolbar { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(branch_diff) = self.branch_diff(cx) else { + return div(); + }; + let focus_handle = branch_diff.focus_handle(cx); + let review_count = branch_diff + .read(cx) + .diff + .read(cx) + .total_review_comment_count(); + let (additions, deletions) = branch_diff + .read(cx) + .diff + .read(cx) + .calculate_changed_lines(cx); + let diff_base = branch_diff.read(cx).diff_base(cx).clone(); + let DiffBase::Merge { base_ref } = diff_base else { + return div(); + }; + let selected_base_ref = base_ref.clone(); + let base_ref_label = format!("Base: {base_ref}"); + let repository = branch_diff.read(cx).repo(cx); + let workspace = branch_diff.read(cx).workspace.clone(); + let view_for_picker = branch_diff.downgrade(); + + let is_multibuffer_empty = branch_diff + .read(cx) + .diff + .read(cx) + .multibuffer() + .read(cx) + .is_empty(); + let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx); + + let show_review_button = !is_multibuffer_empty && is_ai_enabled; + + h_flex() + .my_neg_1() + .py_1() + .gap_1p5() + .flex_wrap() + .justify_between() + .when(!is_multibuffer_empty, |this| { + this.child(DiffStat::new( + "branch-diff-stat", + additions as usize, + deletions as usize, + )) + }) + .child(Divider::vertical().ml_1()) + .child( + PopoverMenu::new("branch-diff-base-branch-picker") + .menu(move |window, cx| { + let view_for_picker = view_for_picker.clone(); + let on_select = Arc::new( + move |branch: git::repository::Branch, + _window: &mut Window, + cx: &mut App| { + let base_ref: SharedString = branch.name().to_owned().into(); + view_for_picker + .update(cx, |branch_diff, cx| { + branch_diff.set_merge_base(base_ref, cx); + cx.notify(); + }) + .ok(); + }, + ); + + Some(branch_picker::select_popover( + workspace.clone(), + repository.clone(), + Some(selected_base_ref.clone()), + on_select, + window, + cx, + )) + }) + .trigger_with_tooltip( + Button::new("branch-diff-base-branch", base_ref_label).end_icon( + Icon::new(IconName::ChevronDown) + .size(IconSize::XSmall) + .color(Color::Muted), + ), + Tooltip::text("Select Base Branch"), + ), + ) + .when(show_review_button, |this| { + let focus_handle = focus_handle.clone(); + this.child(Divider::vertical()).child( + Button::new("review-diff", "Review Diff") + .start_icon( + Icon::new(IconName::ZedAssistant) + .size(IconSize::Small) + .color(Color::Muted), + ) + .tooltip(move |_, cx| { + Tooltip::with_meta_in( + "Review Diff", + Some(&ReviewDiff), + "Send this diff for your last agent to review.", + &focus_handle, + cx, + ) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&ReviewDiff, window, cx); + })), + ) + }) + .when(review_count > 0, |this| { + this.child(Divider::vertical()).child( + render_send_review_to_agent_button(review_count, &focus_handle).on_click( + cx.listener(|this, _, window, cx| { + this.dispatch_action(&SendReviewToAgent, window, cx) + }), + ), + ) + }) + } +} + +#[cfg(test)] +mod tests { + use anyhow::anyhow; + use collections::HashMap; + use editor::test::editor_test_context::assert_state_with_diff; + use git::status::{FileStatus, TrackedStatus, UnmergedStatus, UnmergedStatusCode}; + use gpui::TestAppContext; + use project::FakeFs; + use serde_json::json; + use settings::{DiffViewStyle, SettingsStore}; + use std::path::Path; + use std::sync::Arc; + use unindent::Unindent as _; + use util::{ + path, + rel_path::{RelPath, rel_path}, + }; + use workspace::MultiWorkspace; + + use super::*; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.diff_view_style = Some(DiffViewStyle::Unified); + }); + }); + theme_settings::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + crate::init(cx); + }); + } + + #[gpui::test(iterations = 50)] + async fn test_split_diff_conflict_path_transition_with_dirty_buffer_invalid_anchor_panics( + cx: &mut TestAppContext, + ) { + init_test(cx); + + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.diff_view_style = Some(DiffViewStyle::Split); + }); + }); + }); + + let build_conflict_text: fn(usize) -> String = |tag: usize| { + let mut lines = (0..80) + .map(|line_index| format!("line {line_index}")) + .collect::>(); + for offset in [5usize, 20, 37, 61] { + lines[offset] = format!("base-{tag}-line-{offset}"); + } + format!("{}\n", lines.join("\n")) + }; + let initial_conflict_text = build_conflict_text(0); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "helper.txt": "same\n", + "conflict.txt": initial_conflict_text, + }), + ) + .await; + fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { + state + .refs + .insert("MERGE_HEAD".into(), "conflict-head".into()); + }) + .unwrap(); + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[( + "conflict.txt", + FileStatus::Unmerged(UnmergedStatus { + first_head: UnmergedStatusCode::Updated, + second_head: UnmergedStatusCode::Updated, + }), + )], + ); + fs.set_merge_base_content_for_repo( + path!("/project/.git").as_ref(), + &[ + ("conflict.txt", build_conflict_text(1)), + ("helper.txt", "same\n".to_string()), + ], + ); + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let _branch_diff = cx + .update(|window, cx| { + BranchDiff::new_with_default_branch(project.clone(), workspace, window, cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/conflict.txt"), cx) + }) + .await + .unwrap(); + buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "dirty\n")], None, cx)); + assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty())); + cx.run_until_parked(); + + cx.update(|window, cx| { + let fs = fs.clone(); + window + .spawn(cx, async move |cx| { + cx.background_executor().simulate_random_delay().await; + fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { + state.refs.insert("HEAD".into(), "head-1".into()); + state.refs.remove("MERGE_HEAD"); + }) + .unwrap(); + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[ + ( + "conflict.txt", + FileStatus::Tracked(TrackedStatus { + index_status: git::status::StatusCode::Modified, + worktree_status: git::status::StatusCode::Modified, + }), + ), + ( + "helper.txt", + FileStatus::Tracked(TrackedStatus { + index_status: git::status::StatusCode::Modified, + worktree_status: git::status::StatusCode::Modified, + }), + ), + ], + ); + // FakeFs assigns deterministic OIDs by entry position; flipping order churns + // conflict diff identity without reaching into view internals. + fs.set_merge_base_content_for_repo( + path!("/project/.git").as_ref(), + &[ + ("helper.txt", "helper-base\n".to_string()), + ("conflict.txt", build_conflict_text(2)), + ], + ); + }) + .detach(); + }); + + cx.update(|window, cx| { + let buffer = buffer.clone(); + window + .spawn(cx, async move |cx| { + cx.background_executor().simulate_random_delay().await; + for edit_index in 0..10 { + if edit_index > 0 { + cx.background_executor().simulate_random_delay().await; + } + buffer.update(cx, |buffer, cx| { + let len = buffer.len(); + if edit_index % 2 == 0 { + buffer.edit( + [(0..0, format!("status-burst-head-{edit_index}\n"))], + None, + cx, + ); + } else { + buffer.edit( + [(len..len, format!("status-burst-tail-{edit_index}\n"))], + None, + cx, + ); + } + }); + } + }) + .detach(); + }); + + cx.run_until_parked(); + } + + #[gpui::test] + async fn test_branch_diff(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "a.txt": "C", + "b.txt": "new", + "c.txt": "in-merge-base-and-work-tree", + "d.txt": "created-in-head", + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let diff = cx + .update(|window, cx| { + BranchDiff::new_with_default_branch(project.clone(), workspace, window, cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + fs.set_head_for_repo( + Path::new(path!("/project/.git")), + &[("a.txt", "B".into()), ("d.txt", "created-in-head".into())], + "sha", + ); + fs.set_merge_base_content_for_repo( + Path::new(path!("/project/.git")), + &[ + ("a.txt", "A".into()), + ("c.txt", "in-merge-base-and-work-tree".into()), + ], + ); + cx.run_until_parked(); + + let editor = diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone()); + + assert_state_with_diff( + &editor, + cx, + &" + - A + + ˇC + + new + + created-in-head" + .unindent(), + ); + + let statuses: HashMap, Option> = + editor.update(cx, |editor, cx| { + editor + .buffer() + .read(cx) + .all_buffers() + .iter() + .map(|buffer| { + ( + buffer.read(cx).file().unwrap().path().clone(), + editor.status_for_buffer_id(buffer.read(cx).remote_id(), cx), + ) + }) + .collect() + }); + + assert_eq!( + statuses, + HashMap::from_iter([ + ( + rel_path("a.txt").into_arc(), + Some(FileStatus::Tracked(TrackedStatus { + index_status: git::status::StatusCode::Modified, + worktree_status: git::status::StatusCode::Modified + })) + ), + (rel_path("b.txt").into_arc(), Some(FileStatus::Untracked)), + ( + rel_path("d.txt").into_arc(), + Some(FileStatus::Tracked(TrackedStatus { + index_status: git::status::StatusCode::Added, + worktree_status: git::status::StatusCode::Added + })) + ) + ]) + ); + } + + #[gpui::test] + async fn test_branch_diff_action_matches_existing_item_by_base_ref(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "a.txt": "changed", + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + let target_branch_diff = cx + .update(|window, cx| { + let Some(repository) = project.read(cx).active_repository(cx) else { + return Task::ready(Err(anyhow!("No active repository"))); + }; + BranchDiff::new_with_branch_base( + project.clone(), + workspace.clone(), + "topic".into(), + repository, + window, + cx, + ) + }) + .await + .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane( + Box::new(target_branch_diff.clone()), + None, + true, + window, + cx, + ); + }); + cx.run_until_parked(); + + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(DeployBranchDiff.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let (active_base_ref, mut base_refs) = workspace.update(cx, |workspace, cx| { + let active_item = workspace.active_item_as::(cx).unwrap(); + let active_base_ref = match active_item.read(cx).diff_base(cx) { + DiffBase::Merge { base_ref } => base_ref.to_string(), + DiffBase::Head | DiffBase::Index | DiffBase::Staged => { + panic!("expected active item to be a branch diff") + } + }; + let base_refs = workspace + .items_of_type::(cx) + .filter_map(|item| match item.read(cx).diff_base(cx) { + DiffBase::Merge { base_ref } => Some(base_ref.to_string()), + DiffBase::Head | DiffBase::Index | DiffBase::Staged => None, + }) + .collect::>(); + (active_base_ref, base_refs) + }); + base_refs.sort(); + + assert_eq!(active_base_ref, "origin/main"); + assert_eq!(base_refs, vec!["origin/main", "topic"]); + } +} diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index 9fff74d3983..a45590260da 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -2,8 +2,8 @@ use anyhow::{Context as _, Result}; use buffer_diff::BufferDiff; use collections::HashMap; use editor::{ - Addon, Editor, EditorEvent, EditorSettings, MultiBuffer, SplittableEditor, - hover_markdown_style, multibuffer_context_lines, + Addon, Editor, EditorEvent, EditorSettings, MultiBuffer, RestoreOnlyDiffHunkDelegate, + SplittableEditor, hover_markdown_style, multibuffer_context_lines, }; use futures_lite::future::yield_now; use git::repository::{CommitDetails, CommitDiff, RepoPath, is_binary_content}; @@ -275,7 +275,7 @@ impl CommitView { window, cx, ); - editor.disable_diff_hunk_controls(cx); + editor.set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyDiffHunkDelegate)), cx); editor.rhs_editor().update(cx, |editor, cx| { editor.set_show_bookmarks(false, cx); @@ -1193,7 +1193,7 @@ impl Item for CommitView { window, cx, ); - editor.disable_diff_hunk_controls(cx); + editor.set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyDiffHunkDelegate)), cx); editor.rhs_editor().update(cx, |editor, cx| { editor.set_show_bookmarks(false, cx); editor.set_show_breakpoints(false, cx); diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs index 309a1180781..1199afb9d44 100644 --- a/crates/git_ui/src/conflict_view.rs +++ b/crates/git_ui/src/conflict_view.rs @@ -17,7 +17,7 @@ use project::{ use settings::Settings; use std::{ops::Range, sync::Arc}; use ui::{ButtonLike, Divider, Tooltip, prelude::*}; -use util::{debug_panic, maybe}; +use util::debug_panic; use workspace::{HideStatusItem, StatusItemView, Workspace, item::ItemHandle}; use zed_actions::agent::{ ConflictContent, ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, @@ -115,19 +115,17 @@ pub(crate) fn buffer_ranges_updated( return; } - let buffer_conflicts = editor - .addon_mut::() - .unwrap() - .buffers - .entry(buffer_id) - .or_insert_with(|| { - let subscription = cx.subscribe(&conflict_set, conflicts_updated); - BufferConflicts { - block_ids: Vec::new(), - conflict_set: conflict_set.clone(), - _subscription: subscription, - } - }); + let Some(conflict_addon) = editor.addon_mut::() else { + return; + }; + let buffer_conflicts = conflict_addon.buffers.entry(buffer_id).or_insert_with(|| { + let subscription = cx.subscribe(&conflict_set, conflicts_updated); + BufferConflicts { + block_ids: Vec::new(), + conflict_set: conflict_set.clone(), + _subscription: subscription, + } + }); let conflict_set = buffer_conflicts.conflict_set.clone(); let conflicts_len = conflict_set.read(cx).snapshot().conflicts.len(); @@ -150,18 +148,17 @@ pub(crate) fn buffers_removed( cx: &mut Context, ) { let mut removed_block_ids = HashSet::default(); - editor - .addon_mut::() - .unwrap() - .buffers - .retain(|buffer_id, buffer| { - if removed_buffer_ids.contains(buffer_id) { - removed_block_ids.extend(buffer.block_ids.iter().map(|(_, block_id)| *block_id)); - false - } else { - true - } - }); + let Some(conflict_addon) = editor.addon_mut::() else { + return; + }; + conflict_addon.buffers.retain(|buffer_id, buffer| { + if removed_buffer_ids.contains(buffer_id) { + removed_block_ids.extend(buffer.block_ids.iter().map(|(_, block_id)| *block_id)); + false + } else { + true + } + }); editor.remove_blocks(removed_block_ids, None, cx); } @@ -176,9 +173,13 @@ fn conflicts_updated( let conflict_set = conflict_set.read(cx).snapshot(); let multibuffer = editor.buffer().read(cx); let snapshot = multibuffer.snapshot(cx); - let old_range = maybe!({ - let conflict_addon = editor.addon_mut::().unwrap(); - let buffer_conflicts = conflict_addon.buffers.get(&buffer_id)?; + let old_range = { + let Some(conflict_addon) = editor.addon_mut::() else { + return; + }; + let Some(buffer_conflicts) = conflict_addon.buffers.get(&buffer_id) else { + return; + }; match buffer_conflicts.block_ids.get(event.old_range.clone()) { Some(_) => Some(event.old_range.clone()), None => { @@ -197,10 +198,12 @@ fn conflicts_updated( } } } - }); + }; // Remove obsolete highlights and blocks - let conflict_addon = editor.addon_mut::().unwrap(); + let Some(conflict_addon) = editor.addon_mut::() else { + return; + }; if let Some((buffer_conflicts, old_range)) = conflict_addon .buffers .get_mut(&buffer_id) @@ -256,7 +259,9 @@ fn conflicts_updated( } let new_block_ids = editor.insert_blocks(blocks, None, cx); - let conflict_addon = editor.addon_mut::().unwrap(); + let Some(conflict_addon) = editor.addon_mut::() else { + return; + }; if let Some((buffer_conflicts, old_range)) = conflict_addon.buffers.get_mut(&buffer_id).zip(old_range) { @@ -481,7 +486,7 @@ pub(crate) fn resolve_conflict( let buffer_id = resolved_conflict.ours.end.buffer_id; let buffer = multibuffer.read(cx).buffer(buffer_id)?; resolved_conflict.resolve(buffer.clone(), &ranges, cx); - let conflict_addon = editor.addon_mut::().unwrap(); + let conflict_addon = editor.addon_mut::()?; let snapshot = multibuffer.read(cx).snapshot(cx); let buffer_snapshot = buffer.read(cx).snapshot(); let state = conflict_addon diff --git a/crates/git_ui/src/diff_multibuffer.rs b/crates/git_ui/src/diff_multibuffer.rs new file mode 100644 index 00000000000..96c05303719 --- /dev/null +++ b/crates/git_ui/src/diff_multibuffer.rs @@ -0,0 +1,1016 @@ +use crate::{ + conflict_view, + git_panel::{GitPanel, GitStatusEntry}, + git_panel_settings::GitPanelSettings, +}; +use anyhow::Result; +use buffer_diff::BufferDiff; +use collections::{HashMap, HashSet}; +use editor::{ + EditorEvent, EditorSettings, SelectionEffects, SplittableEditor, actions::GoToHunk, + multibuffer_context_lines, scroll::Autoscroll, +}; +use futures_lite::future::yield_now; +use git::{repository::RepoPath, status::FileStatus}; +use gpui::{ + App, AppContext as _, AsyncWindowContext, Entity, EventEmitter, FocusHandle, Focusable, Render, + SharedString, Subscription, Task, WeakEntity, +}; +use language::{Anchor, Buffer, BufferId, Capability, OffsetRangeExt}; +use multi_buffer::{MultiBuffer, PathKey}; +use project::{ + ConflictSet, Project, ProjectPath, + git_store::{ + Repository, + diff_buffer_list::{self, BranchDiffEvent, DiffBase}, + }, +}; +use settings::{GitPanelGroupBy, GitPanelSortBy, Settings, SettingsStore}; +use std::{collections::BTreeMap, sync::Arc}; +use theme::ActiveTheme; +use ui::{CommonAnimationExt as _, KeyBinding, prelude::*}; +use util::{ResultExt as _, rel_path::RelPath}; +use workspace::{ + CloseActiveItem, ItemNavHistory, Workspace, + item::{Item, SaveOptions}, +}; +use ztracing::instrument; + +struct BufferSubscriptions { + _diff: Entity, + display_buffer: Entity, + _diff_subscription: Subscription, + _conflict_set: Option>, + _conflict_set_subscription: Option, +} + +pub struct DiffMultibuffer { + multibuffer: Entity, + branch_diff: Entity, + editor: Entity, + buffer_subscriptions: HashMap, + workspace: WeakEntity, + focus_handle: FocusHandle, + pending_scroll: Option, + review_comment_count: usize, + empty_label: SharedString, + _task: Task>, + _subscription: Subscription, +} + +impl DiffMultibuffer { + pub(crate) fn new( + branch_diff: Entity, + multibuffer_capability: Capability, + empty_label: impl Into, + configure_editor: impl FnOnce(&mut SplittableEditor, &mut Context) + 'static, + project: Entity, + workspace: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let focus_handle = cx.focus_handle(); + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(multibuffer_capability); + multibuffer.set_all_diff_hunks_expanded(cx); + multibuffer + }); + let editor = cx.new(|cx| { + let mut diff_display_editor = SplittableEditor::new( + EditorSettings::get_global(cx).diff_view_style, + multibuffer.clone(), + project.clone(), + workspace.clone(), + window, + cx, + ); + configure_editor(&mut diff_display_editor, cx); + diff_display_editor.rhs_editor().update(cx, |editor, cx| { + editor.set_show_diff_review_button(true, cx); + }); + diff_display_editor + }); + let editor_subscription = cx.subscribe_in(&editor, window, Self::handle_editor_event); + + let primary_editor = editor.read(cx).rhs_editor().clone(); + let review_comment_subscription = + cx.subscribe(&primary_editor, |this, _editor, event: &EditorEvent, cx| { + if let EditorEvent::ReviewCommentsChanged { total_count } = event { + this.review_comment_count = *total_count; + cx.notify(); + } + }); + + let branch_diff_subscription = cx.subscribe_in( + &branch_diff, + window, + move |this, _git_store, event, window, cx| match event { + BranchDiffEvent::FileListChanged => { + this._task = window.spawn(cx, { + let this = cx.weak_entity(); + async |cx| Self::refresh(this, cx).await + }) + } + BranchDiffEvent::DiffBaseChanged => { + this.pending_scroll.take(); + this._task = window.spawn(cx, { + let this = cx.weak_entity(); + async |cx| Self::refresh(this, cx).await + }) + } + }, + ); + + let mut was_sort_by = GitPanelSettings::get_global(cx).sort_by; + let mut was_group_by = GitPanelSettings::get_global(cx).group_by; + let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view; + let mut was_collapse_untracked_diff = + GitPanelSettings::get_global(cx).collapse_untracked_diff; + cx.observe_global_in::(window, move |this, window, cx| { + let settings = GitPanelSettings::get_global(cx); + let sort_by = settings.sort_by; + let group_by = settings.group_by; + let tree_view = settings.tree_view; + let is_collapse_untracked_diff = settings.collapse_untracked_diff; + if sort_by != was_sort_by + || group_by != was_group_by + || tree_view != was_tree_view + || is_collapse_untracked_diff != was_collapse_untracked_diff + { + this._task = { + window.spawn(cx, { + let this = cx.weak_entity(); + async |cx| Self::refresh(this, cx).await + }) + } + } + was_sort_by = sort_by; + was_group_by = group_by; + was_tree_view = tree_view; + was_collapse_untracked_diff = is_collapse_untracked_diff; + }) + .detach(); + + let task = window.spawn(cx, { + let this = cx.weak_entity(); + async |cx| Self::refresh(this, cx).await + }); + + Self { + workspace: workspace.downgrade(), + branch_diff, + focus_handle, + editor, + multibuffer, + buffer_subscriptions: Default::default(), + pending_scroll: None, + review_comment_count: 0, + empty_label: empty_label.into(), + _task: task, + _subscription: Subscription::join( + branch_diff_subscription, + Subscription::join(editor_subscription, review_comment_subscription), + ), + } + } + + pub(crate) fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase { + self.branch_diff.read(cx).diff_base() + } + + pub(crate) fn branch_diff(&self) -> &Entity { + &self.branch_diff + } + + pub(crate) fn repo(&self, cx: &App) -> Option> { + self.branch_diff.read(cx).repo().cloned() + } + + pub(crate) fn set_repo(&mut self, repo: Option>, cx: &mut Context) { + self.branch_diff.update(cx, |branch_diff, cx| { + branch_diff.set_repo(repo, cx); + }); + } + + pub(crate) fn is_dirty(&self, cx: &App) -> bool { + self.multibuffer.read(cx).is_dirty(cx) + } + + pub(crate) fn has_conflict(&self, cx: &App) -> bool { + self.multibuffer.read(cx).has_conflict(cx) + } + + pub(crate) fn multibuffer(&self) -> &Entity { + &self.multibuffer + } + + pub(crate) fn move_to_entry( + &mut self, + entry: GitStatusEntry, + window: &mut Window, + cx: &mut Context, + ) { + let Some(git_repo) = self.branch_diff.read(cx).repo() else { + return; + }; + let repo = git_repo.read(cx); + let path_key = project_diff_path_key(repo, &entry.repo_path, entry.status, cx); + + self.move_to_path(path_key, window, cx) + } + + pub(crate) fn move_to_project_path( + &mut self, + project_path: &ProjectPath, + window: &mut Window, + cx: &mut Context, + ) { + let Some(git_repo) = self.branch_diff.read(cx).repo() else { + return; + }; + let Some(repo_path) = git_repo + .read(cx) + .project_path_to_repo_path(project_path, cx) + else { + return; + }; + let status = git_repo + .read(cx) + .status_for_path(&repo_path) + .map(|entry| entry.status) + .unwrap_or(FileStatus::Untracked); + let path_key = project_diff_path_key(&git_repo.read(cx), &repo_path, status, cx); + self.move_to_path(path_key, window, cx) + } + + pub(crate) fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |editor, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges(vec![multi_buffer::Anchor::Min..multi_buffer::Anchor::Min]); + }); + }); + }); + } + + pub(crate) fn move_to_path( + &mut self, + path_key: PathKey, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |editor, cx| { + editor.change_selections( + SelectionEffects::scroll(Autoscroll::focused()), + window, + cx, + |s| { + s.select_ranges([position..position]); + }, + ) + }) + }); + } else { + self.pending_scroll = Some(path_key); + } + } + + pub(crate) fn autoscroll(&self, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |editor, cx| { + editor.request_autoscroll(Autoscroll::fit(), cx); + }) + }) + } + + pub(crate) fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) { + self.multibuffer.read(cx).snapshot(cx).total_changed_lines() + } + + /// Returns the total count of review comments across all hunks/files. + pub(crate) fn total_review_comment_count(&self) -> usize { + self.review_comment_count + } + + /// Returns a reference to the splittable editor. + pub(crate) fn editor(&self) -> &Entity { + &self.editor + } + + pub(crate) fn selected_ranges( + &self, + cx: &App, + ) -> (bool, Vec>) { + let editor = self.editor.read(cx).rhs_editor().read(cx); + let snapshot = self.multibuffer.read(cx).snapshot(cx); + let mut selection = true; + let mut ranges = editor + .selections + .disjoint_anchor_ranges() + .collect::>(); + if !ranges.iter().any(|range| range.start != range.end) { + selection = false; + let anchor = editor.selections.newest_anchor().head(); + if let Some((_, excerpt_range)) = snapshot.excerpt_containing(anchor..anchor) + && let Some(range) = snapshot + .anchor_in_buffer(excerpt_range.context.start) + .zip(snapshot.anchor_in_buffer(excerpt_range.context.end)) + .map(|(start, end)| start..end) + { + ranges = vec![range]; + } else { + ranges = Vec::default(); + }; + } + + (selection, ranges) + } + + /// Ranges for a toolbar stage/unstage action: the selection, or the cursor + /// (a zero-width range that resolves to the single hunk under it) when + /// there is no selection. Unlike [`Self::selected_ranges`], this never + /// widens to the whole excerpt, so actions affect one hunk at a time. + fn hunk_action_ranges(&self, cx: &App) -> Vec> { + self.editor + .read(cx) + .rhs_editor() + .read(cx) + .selections + .disjoint_anchor_ranges() + .collect() + } + + pub(crate) fn stage_or_unstage_selected_hunks( + &mut self, + stage: bool, + move_to_next: bool, + window: &mut Window, + cx: &mut Context, + ) { + let editor = self.editor.read(cx).rhs_editor().clone(); + let ranges = self.hunk_action_ranges(cx); + // Route through the editor's delegated stage path, the same path taken + // by the hunk buttons (on either side of a split) and the keyboard. + // For staging, dirty buffers are saved first, exactly as they are when + // staging from the uncommitted diff or a normal editor. + editor.update(cx, |editor, cx| { + editor.stage_or_unstage_diff_hunks(stage, ranges, window, cx); + }); + if move_to_next { + editor + .focus_handle(cx) + .dispatch_action(&GoToHunk, window, cx); + } + } + + fn handle_editor_event( + &mut self, + editor: &Entity, + event: &EditorEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + EditorEvent::SelectionsChanged { local: true } => { + // Only follow the git panel selection from the view the user is + // actually interacting with. Background (non-active) diff views + // refresh on their own and must not hijack the panel selection. + if !editor.focus_handle(cx).contains_focused(window, cx) { + return; + } + let Some(project_path) = self.active_project_path(cx) else { + return; + }; + self.workspace + .update(cx, |workspace, cx| { + if let Some(git_panel) = workspace.panel::(cx) { + git_panel.update(cx, |git_panel, cx| { + git_panel.select_entry_by_path(project_path, window, cx) + }) + } + }) + .ok(); + } + EditorEvent::Saved => { + self._task = + cx.spawn_in(window, async move |this, cx| Self::refresh(this, cx).await); + } + + _ => {} + } + if editor.focus_handle(cx).contains_focused(window, cx) + && self.multibuffer.read(cx).is_empty() + { + self.focus_handle.focus(window, cx) + } + } + + #[instrument(skip_all)] + fn register_buffer( + &mut self, + repo_path: RepoPath, + path_key: PathKey, + file_status: FileStatus, + display_buffer: Entity, + main_buffer: Entity, + diff: Entity, + conflict_set: Option>, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let diff_subscription = cx.subscribe_in(&diff, window, { + let repo_path = repo_path.clone(); + let path_key = path_key.clone(); + let display_buffer = display_buffer.clone(); + let main_buffer = main_buffer.clone(); + let diff = diff.clone(); + let conflict_set = conflict_set.clone(); + move |this, _, event, window, cx| match event { + buffer_diff::BufferDiffEvent::DiffChanged(_) => { + this.buffer_ranges_changed( + repo_path.clone(), + path_key.clone(), + file_status, + display_buffer.clone(), + main_buffer.clone(), + diff.clone(), + conflict_set.clone(), + window, + cx, + ); + } + buffer_diff::BufferDiffEvent::BaseTextChanged => {} + } + }); + let conflict_set_subscription = conflict_set.as_ref().map(|conflict_set| { + cx.subscribe_in(conflict_set, window, { + let repo_path = repo_path.clone(); + let path_key = path_key.clone(); + let display_buffer = display_buffer.clone(); + let main_buffer = main_buffer.clone(); + let diff = diff.clone(); + let conflict_set = Some(conflict_set.clone()); + move |this, _, _, window, cx| { + this.buffer_ranges_changed( + repo_path.clone(), + path_key.clone(), + file_status, + display_buffer.clone(), + main_buffer.clone(), + diff.clone(), + conflict_set.clone(), + window, + cx, + ) + } + }) + }); + self.buffer_subscriptions.insert( + repo_path, + BufferSubscriptions { + _diff: diff.clone(), + display_buffer: display_buffer.clone(), + _diff_subscription: diff_subscription, + _conflict_set: conflict_set.clone(), + _conflict_set_subscription: conflict_set_subscription, + }, + ); + + let snapshot = display_buffer.read(cx).snapshot(); + let diff_snapshot = diff.read(cx).snapshot(cx); + + let excerpt_ranges = { + let diff_hunk_ranges = diff_snapshot + .hunks_intersecting_range( + Anchor::min_max_range_for_buffer(snapshot.remote_id()), + &snapshot, + ) + .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot)); + let conflict_ranges = conflict_set.as_ref().and_then(|conflict_set| { + let conflicts = conflict_set.read(cx).snapshot(); + let conflicts = conflicts + .conflicts + .iter() + .map(|conflict| conflict.range.to_point(&snapshot)) + .collect::>(); + (!conflicts.is_empty()).then_some(conflicts) + }); + + conflict_ranges.unwrap_or_else(|| diff_hunk_ranges.collect()) + }; + + let buffer_id = snapshot.text.remote_id(); + let mut needs_fold = false; + + let (was_empty, is_excerpt_newly_added) = self.editor.update(cx, |editor, cx| { + let was_empty = editor.rhs_editor().read(cx).buffer().read(cx).is_empty(); + let is_newly_added = editor.update_excerpts_for_path( + path_key.clone(), + display_buffer, + excerpt_ranges, + multibuffer_context_lines(cx), + diff, + cx, + ); + if let Some(conflict_set) = conflict_set { + editor.rhs_editor().update(cx, |editor, cx| { + conflict_view::buffer_ranges_updated(editor, conflict_set, cx); + }); + } + (was_empty, is_newly_added) + }); + + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |editor, cx| { + if was_empty { + editor.change_selections( + SelectionEffects::no_scroll(), + window, + cx, + |selections| { + selections.select_ranges([ + multi_buffer::Anchor::Min..multi_buffer::Anchor::Min + ]) + }, + ); + } + if is_excerpt_newly_added + && (file_status.is_deleted() + || (file_status.is_untracked() + && GitPanelSettings::get_global(cx).collapse_untracked_diff)) + { + needs_fold = true; + } + }) + }); + + if self.multibuffer.read(cx).is_empty() + && self + .editor + .read(cx) + .focus_handle(cx) + .contains_focused(window, cx) + { + self.focus_handle.focus(window, cx); + } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() { + self.editor.update(cx, |editor, cx| { + editor.focus_handle(cx).focus(window, cx); + }); + } + if self.pending_scroll.as_ref() == Some(&path_key) { + self.move_to_path(path_key, window, cx); + } + + needs_fold.then_some(buffer_id) + } + + fn buffer_ranges_changed( + &mut self, + repo_path: RepoPath, + path_key: PathKey, + file_status: FileStatus, + display_buffer: Entity, + main_buffer: Entity, + diff: Entity, + conflict_set: Option>, + window: &mut Window, + cx: &mut Context, + ) { + if display_buffer.read(cx).is_dirty() { + return; + } + self.register_buffer( + repo_path, + path_key, + file_status, + display_buffer, + main_buffer, + diff, + conflict_set, + window, + cx, + ); + } + + #[instrument(skip(this, cx))] + pub(crate) async fn refresh(this: WeakEntity, cx: &mut AsyncWindowContext) -> Result<()> { + let entries = this.update(cx, |this, cx| { + let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| { + let load_buffers = branch_diff.load_buffers(cx); + (branch_diff.repo().cloned(), load_buffers) + }); + let mut previous_paths = this + .multibuffer + .read(cx) + .snapshot(cx) + .buffers_with_paths() + .map(|(buffer_snapshot, path_key)| (path_key.clone(), buffer_snapshot.remote_id())) + .collect::>(); + + let mut entries = BTreeMap::new(); + let mut live_repo_paths = HashSet::default(); + if let Some(repo) = repo { + let repo = repo.read(cx); + for diff_buffer in buffers_to_load { + live_repo_paths.insert(diff_buffer.repo_path.clone()); + let path_key = project_diff_path_key( + &repo, + &diff_buffer.repo_path, + diff_buffer.file_status, + cx, + ); + previous_paths.remove(&path_key); + entries.insert(path_key, diff_buffer); + } + } + + let repo_path_by_display_id = this + .buffer_subscriptions + .iter() + .map(|(repo_path, sub)| { + (sub.display_buffer.read(cx).remote_id(), repo_path.clone()) + }) + .collect::>(); + + this.editor.update(cx, |editor, cx| { + for (path, buffer_id) in previous_paths { + if let Some(repo_path) = repo_path_by_display_id.get(&buffer_id) { + this.buffer_subscriptions.remove(repo_path); + } + editor.rhs_editor().update(cx, |editor, cx| { + conflict_view::buffers_removed(editor, &[buffer_id], cx); + }); + let _span = ztracing::info_span!("remove_excerpts_for_path"); + _span.enter(); + editor.remove_excerpts_for_path(path, cx); + } + }); + + this.buffer_subscriptions + .retain(|repo_path, _| live_repo_paths.contains(repo_path)); + + entries + })?; + + let mut buffers_to_fold = Vec::new(); + + for (path_key, entry) in entries { + if let Some(loaded_buffer) = entry.load.await.log_err() { + // We might be lagging behind enough that all future entry.load futures are no longer pending. + // If that is the case, this task will never yield, starving the foreground thread of execution time. + yield_now().await; + cx.update(|window, cx| { + this.update(cx, |this, cx| { + if let Some(buffer_id) = this.register_buffer( + entry.repo_path, + path_key, + entry.file_status, + loaded_buffer.display_buffer, + loaded_buffer.main_buffer, + loaded_buffer.diff, + loaded_buffer.conflict_set, + window, + cx, + ) { + buffers_to_fold.push(buffer_id); + } + }) + .ok(); + })?; + } + } + this.update(cx, |this, cx| { + if !buffers_to_fold.is_empty() { + this.editor.update(cx, |editor, cx| { + editor + .rhs_editor() + .update(cx, |editor, cx| editor.fold_buffers(buffers_to_fold, cx)); + }); + } + this.pending_scroll.take(); + cx.notify(); + })?; + + Ok(()) + } + + pub(crate) fn active_project_path(&self, cx: &App) -> Option { + let editor = self.editor.read(cx).focused_editor().read(cx); + let multibuffer = editor.buffer().read(cx); + let position = editor.selections.newest_anchor().head(); + let snapshot = multibuffer.snapshot(cx); + let (text_anchor, _) = snapshot.anchor_to_buffer_anchor(position)?; + let buffer = multibuffer.buffer(text_anchor.buffer_id)?; + + let file = buffer.read(cx).file()?; + Some(ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path().clone(), + }) + } + + pub(crate) fn added_to_workspace( + &mut self, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + self.editor.update(cx, |editor, cx| { + editor.added_to_workspace(workspace, window, cx) + }); + } + + pub(crate) fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |primary_editor, cx| { + primary_editor.deactivated(window, cx); + }) + }); + } + + pub(crate) fn navigate( + &mut self, + data: Arc, + window: &mut Window, + cx: &mut Context, + ) -> bool { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |primary_editor, cx| { + primary_editor.navigate(data, window, cx) + }) + }) + } + + pub(crate) fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |primary_editor, _| { + primary_editor.set_nav_history(Some(nav_history)); + }) + }); + } + + pub(crate) fn for_each_project_item( + &self, + cx: &App, + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), + ) { + self.editor + .read(cx) + .rhs_editor() + .read(cx) + .for_each_project_item(cx, f) + } + + pub(crate) fn save( + &mut self, + options: SaveOptions, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |primary_editor, cx| { + primary_editor.save(options, project, window, cx) + }) + }) + } + + pub(crate) fn reload( + &mut self, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |primary_editor, cx| { + primary_editor.reload(project, window, cx) + }) + }) + } + + #[cfg(any(test, feature = "test-support"))] + pub fn excerpt_paths(&self, cx: &App) -> Vec> { + let snapshot = self + .editor() + .read(cx) + .rhs_editor() + .read(cx) + .buffer() + .read(cx) + .snapshot(cx); + snapshot + .excerpts() + .map(|excerpt| { + snapshot + .path_for_buffer(excerpt.context.start.buffer_id) + .unwrap() + .path + .clone() + }) + .collect() + } + + /// Returns the real (worktree-relative) path of each excerpted buffer, in + /// the order the excerpts appear in the multibuffer. Unlike + /// [`Self::excerpt_paths`], this resolves the buffer's actual `File` rather + /// than the (possibly synthetic) `PathKey` path used for sorting. + #[cfg(any(test, feature = "test-support"))] + pub fn excerpt_file_paths(&self, cx: &App) -> Vec { + let multibuffer = self + .editor() + .read(cx) + .rhs_editor() + .read(cx) + .buffer() + .clone(); + let snapshot = multibuffer.read(cx).snapshot(cx); + let mut result = Vec::new(); + let mut last_buffer_id = None; + for excerpt in snapshot.excerpts() { + let buffer_id = excerpt.context.start.buffer_id; + if last_buffer_id == Some(buffer_id) { + continue; + } + last_buffer_id = Some(buffer_id); + if let Some(buffer) = multibuffer.read(cx).buffer(buffer_id) + && let Some(file) = buffer.read(cx).file() + { + result.push(file.path().as_unix_str().to_string()); + } + } + result + } +} + +impl EventEmitter for DiffMultibuffer {} + +impl Focusable for DiffMultibuffer { + fn focus_handle(&self, cx: &App) -> FocusHandle { + if self.multibuffer.read(cx).is_empty() { + self.focus_handle.clone() + } else { + self.editor.focus_handle(cx) + } + } +} + +impl Render for DiffMultibuffer { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_empty = self.multibuffer.read(cx).is_empty(); + let is_loading = self.branch_diff.read(cx).is_tree_base_loading() || !self._task.is_ready(); + let empty_label = self.empty_label.clone(); + + div() + .track_focus(&self.focus_handle) + .key_context(if is_empty { "EmptyPane" } else { "GitDiff" }) + .bg(cx.theme().colors().editor_background) + .flex() + .items_center() + .justify_center() + .size_full() + .when(is_empty && is_loading, |el| { + let rems = TextSize::Large.rems(cx); + el.child( + Icon::new(IconName::LoadCircle) + .size(IconSize::Custom(rems)) + .color(Color::Accent) + .with_rotate_animation(3) + .into_any_element(), + ) + }) + .when(is_empty && !is_loading, |el| { + let remote_button = if let Some(panel) = self + .workspace + .upgrade() + .and_then(|workspace| workspace.read(cx).panel::(cx)) + { + panel.update(cx, |panel, cx| panel.render_remote_button(cx)) + } else { + None + }; + let keybinding_focus_handle = self.focus_handle(cx); + el.child( + v_flex() + .gap_1() + .child(h_flex().justify_around().child(Label::new(empty_label))) + .map(|el| match remote_button { + Some(button) => el.child(h_flex().justify_around().child(button)), + None => el.child( + h_flex() + .justify_around() + .child(Label::new("Remote up to date")), + ), + }) + .child( + h_flex().justify_around().mt_1().child( + Button::new("project-diff-close-button", "Close") + .key_binding(KeyBinding::for_action_in( + &CloseActiveItem::default(), + &keybinding_focus_handle, + cx, + )) + .on_click(move |_, window, cx| { + window.focus(&keybinding_focus_handle, cx); + window.dispatch_action( + Box::new(CloseActiveItem::default()), + cx, + ); + }), + ), + ), + ) + }) + .when(!is_empty, |el| el.child(self.editor.clone())) + } +} + +const CONFLICT_SORT_PREFIX: u64 = 1; +const TRACKED_SORT_PREFIX: u64 = 2; +const NEW_SORT_PREFIX: u64 = 3; + +/// Computes a stable [`PathKey`] for a buffer in the project diff. +/// +/// The key is an intrinsic function of the file's own repo path and status; it +/// never depends on which other buffers happen to be present in the +/// multibuffer. This is required because the multibuffer uses the path key both +/// to order excerpts and to identify which excerpts belong to a given buffer, so +/// a key that shifted as files were added or removed would break that identity. +/// +/// Status grouping is encoded in the `sort_prefix`, and the within-group order +/// is encoded in the (possibly synthetic) path so that `PathKey`'s natural +/// ordering reproduces the git panel's order. The path here is only ever used +/// for sorting and multibuffer identity; the path shown in the UI comes from the +/// buffer's own `File`. +pub(crate) fn project_diff_path_key( + repo: &Repository, + repo_path: &RepoPath, + status: FileStatus, + cx: &App, +) -> PathKey { + let settings = GitPanelSettings::get_global(cx); + let sort_prefix = if settings.group_by != GitPanelGroupBy::Status { + TRACKED_SORT_PREFIX + } else if repo.had_conflict_on_last_merge_head_change(repo_path) { + CONFLICT_SORT_PREFIX + } else if status.is_created() { + NEW_SORT_PREFIX + } else { + TRACKED_SORT_PREFIX + }; + let path = project_diff_sort_path(repo_path, settings.tree_view, settings.sort_by); + PathKey::with_sort_prefix(sort_prefix, path) +} + +fn project_diff_sort_path( + repo_path: &RelPath, + tree_view: bool, + sort_by: GitPanelSortBy, +) -> Arc { + if tree_view { + tree_sort_path(repo_path) + } else { + match sort_by { + GitPanelSortBy::Path => repo_path.into_arc(), + GitPanelSortBy::Name => name_sort_path(repo_path), + } + } +} + +/// Builds a synthetic path that sorts by file name first, falling back to the +/// full path to keep the key unique per file. +fn name_sort_path(repo_path: &RelPath) -> Arc { + let Some(file_name) = repo_path.file_name() else { + return repo_path.into_arc(); + }; + let synthetic = format!("{}/{}", file_name, repo_path.as_unix_str()); + RelPath::unix(&synthetic) + .map(|path| path.into_arc()) + .unwrap_or_else(|_| repo_path.into_arc()) +} + +/// Builds a synthetic path whose natural component-wise ordering reproduces a +/// folder-first tree order. Each directory component is prefixed with a NUL +/// byte, which can never appear in a real path component and sorts before every +/// printable character, so at each level directories sort before files. +fn tree_sort_path(repo_path: &RelPath) -> Arc { + let components: Vec<&str> = repo_path.components().collect(); + if components.len() <= 1 { + return repo_path.into_arc(); + } + let last = components.len() - 1; + let mut synthetic = String::new(); + for (index, component) in components.into_iter().enumerate() { + if index > 0 { + synthetic.push('/'); + } + if index < last { + synthetic.push('\0'); + } + synthetic.push_str(component); + } + RelPath::unix(&synthetic) + .map(|path| path.into_arc()) + .unwrap_or_else(|_| repo_path.into_arc()) +} diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs index 477f18be545..b046380b28a 100644 --- a/crates/git_ui/src/file_diff_view.rs +++ b/crates/git_ui/src/file_diff_view.rs @@ -2,7 +2,10 @@ use anyhow::Result; use buffer_diff::BufferDiff; -use editor::{Editor, EditorEvent, EditorSettings, MultiBuffer, SplittableEditor}; +use editor::{ + Editor, EditorEvent, EditorSettings, MultiBuffer, RestoreOnlyUnstagedDiffHunkDelegate, + SplittableEditor, +}; use futures::{FutureExt, select_biased}; use gpui::{ AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, @@ -103,11 +106,8 @@ impl FileDiffView { window, cx, ); - splittable.rhs_editor().update(cx, |editor, _| { - editor.start_temporary_diff_override(); - }); - splittable.disable_diff_hunk_controls(cx); - splittable.set_render_diff_hunks_as_unstaged(cx); + splittable + .set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyUnstagedDiffHunkDelegate)), cx); splittable }); diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index e2b04699b22..90463f1b9e1 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -3,9 +3,11 @@ use crate::commit_modal::CommitModal; use crate::commit_tooltip::{CommitAvatar, CommitTooltip}; use crate::commit_view::CommitView; use crate::git_panel_settings::GitPanelScrollbarAccessor; -use crate::project_diff::{BranchDiff, Diff, ProjectDiff}; +use crate::project_diff::{DeployBranchDiff, Diff, ProjectDiff}; use crate::remote_output::{self, RemoteAction, SuccessMessage}; use crate::solo_diff_view::SoloDiffView; +use crate::staged_diff::StagedDiff; +use crate::unstaged_diff::UnstagedDiff; use crate::{branch_picker, picker_prompt, render_remote_button}; use crate::{ git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector, @@ -37,10 +39,11 @@ use git::{ ViewFile, parse_git_remote_url, }; use gpui::{ - AbsoluteLength, Action, Anchor, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, DismissEvent, - Empty, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, MouseButton, MouseDownEvent, - Point, PromptLevel, ScrollStrategy, Subscription, Task, TaskExt, TextStyle, - UniformListScrollHandle, WeakEntity, actions, anchored, deferred, point, size, uniform_list, + AbsoluteLength, Action, Anchor, AnyElement, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, + DismissEvent, Empty, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, MouseButton, + MouseDownEvent, Pixels, Point, PromptLevel, ScrollStrategy, Subscription, Task, TaskExt, + TextStyle, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, point, size, + uniform_list, }; use itertools::Itertools; use language::{Buffer, BufferEvent, File}; @@ -137,6 +140,10 @@ actions!( ExpandSelectedEntry, /// Collapses the selected entry to hide its children. CollapseSelectedEntry, + /// View unstaged changes + ViewUnstagedChanges, + /// View staged changes + ViewStagedChanges, /// Activates the Changes tab. ActivateChangesTab, /// Activates the History tab. @@ -3883,6 +3890,40 @@ impl GitPanel { } } + fn view_staged_changes( + &mut self, + _: &ViewStagedChanges, + window: &mut Window, + cx: &mut Context, + ) { + let entry = self + .get_selected_entry() + .and_then(|entry| entry.status_entry()) + .cloned(); + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + StagedDiff::deploy_at(workspace, entry, window, cx); + }); + } + } + + fn view_unstaged_changes( + &mut self, + _: &ViewUnstagedChanges, + window: &mut Window, + cx: &mut Context, + ) { + let entry = self + .get_selected_entry() + .and_then(|entry| entry.status_entry()) + .cloned(); + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + UnstagedDiff::deploy_at(workspace, entry, window, cx); + }); + } + } + fn toggle_tree_view(&mut self, _: &ToggleTreeView, _: &mut Window, cx: &mut Context) { let current_setting = GitPanelSettings::get_global(cx).tree_view; if let Some(workspace) = self.workspace.upgrade() { @@ -6082,7 +6123,7 @@ impl GitPanel { .style(ButtonStyle::Outlined) .on_click(move |_, _, cx| { cx.defer(move |cx| { - cx.dispatch_action(&BranchDiff); + cx.dispatch_action(&DeployBranchDiff); }) }), ) @@ -6510,6 +6551,9 @@ impl GitPanel { .action(stage_title, ToggleStaged.boxed_clone()) .action(restore_title, git::RestoreFile::default().boxed_clone()) .separator() + .action("Unstaged Changes", ViewUnstagedChanges.boxed_clone()) + .action("Staged Changes", ViewStagedChanges.boxed_clone()) + .separator() .action_disabled_when( !is_created, "Add to .gitignore", @@ -7294,6 +7338,8 @@ impl Render for GitPanel { .on_action(cx.listener(Self::open_diff)) .on_action(cx.listener(Self::open_solo_diff)) .on_action(cx.listener(Self::view_file)) + .on_action(cx.listener(Self::view_unstaged_changes)) + .on_action(cx.listener(Self::view_staged_changes)) .on_action(cx.listener(Self::focus_changes_list)) .on_action(cx.listener(Self::focus_editor)) .on_action(cx.listener(Self::expand_commit_editor)) diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 08192d70c0f..aefd0eff50a 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -34,12 +34,14 @@ use crate::{ }; mod askpass_modal; +pub mod branch_diff; pub mod branch_picker; mod commit_modal; pub mod commit_tooltip; pub mod commit_view; mod conflict_view; pub mod created_worktrees; +mod diff_multibuffer; pub mod file_diff_view; pub mod git_graph; pub mod git_panel; @@ -52,8 +54,10 @@ pub mod project_diff; pub(crate) mod remote_output; pub mod repository_selector; pub mod solo_diff_view; +pub mod staged_diff; pub mod stash_picker; pub mod text_diff_view; +pub mod unstaged_diff; pub mod worktree_names; pub mod worktree_picker; pub mod worktree_service; @@ -87,6 +91,9 @@ pub fn init(cx: &mut App) { cx.observe_new(|workspace: &mut Workspace, _, cx| { ProjectDiff::register(workspace, cx); + staged_diff::StagedDiff::register(workspace, cx); + unstaged_diff::UnstagedDiff::register(workspace, cx); + branch_diff::BranchDiff::register(workspace, cx); CommitModal::register(workspace); git_panel::register(workspace); repository_selector::register(workspace); diff --git a/crates/git_ui/src/multi_diff_view.rs b/crates/git_ui/src/multi_diff_view.rs index f8097e68f5c..7bf7682938c 100644 --- a/crates/git_ui/src/multi_diff_view.rs +++ b/crates/git_ui/src/multi_diff_view.rs @@ -1,7 +1,10 @@ use crate::file_diff_view::build_buffer_diff; use anyhow::Result; use buffer_diff::BufferDiff; -use editor::{Editor, EditorEvent, MultiBuffer, multibuffer_context_lines}; +use editor::{ + Editor, EditorEvent, MultiBuffer, RestoreOnlyUnstagedDiffHunkDelegate, + multibuffer_context_lines, +}; use gpui::{ AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, Focusable, Font, IntoElement, Render, SharedString, Task, Window, @@ -196,14 +199,9 @@ impl MultiDiffView { let editor = cx.new(|cx| { let mut editor = Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx); - editor.start_temporary_diff_override(); + editor.set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyUnstagedDiffHunkDelegate)), cx); editor.disable_diagnostics(cx); editor.set_expand_all_diff_hunks(cx); - editor.set_render_diff_hunks_as_unstaged(true, cx); - editor.set_render_diff_hunk_controls( - Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()), - cx, - ); editor }); diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 91f351bec29..d674be55766 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -1,56 +1,41 @@ use crate::{ - branch_picker, conflict_view, + diff_multibuffer::DiffMultibuffer, git_panel::{GitPanel, GitPanelAddon, GitStatusEntry}, - git_panel_settings::GitPanelSettings, + staged_diff::StagedDiff, + unstaged_diff::UnstagedDiff, }; -use agent_settings::AgentSettings; -use anyhow::{Context as _, Result, anyhow}; -use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus}; -use collections::HashMap; +use anyhow::{Context as _, Result}; +use buffer_diff::DiffHunkSecondaryStatus; use editor::{ - Addon, Editor, EditorEvent, EditorSettings, SelectionEffects, SplittableEditor, + Editor, EditorEvent, SplittableEditor, UncommittedDiffHunkDelegate, actions::{GoToHunk, GoToPreviousHunk, SendReviewToAgent}, - multibuffer_context_lines, - scroll::Autoscroll, -}; -use futures_lite::future::yield_now; -use git::repository::DiffType; - -use git::{ - Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext, repository::RepoPath, - status::FileStatus, }; +use git::{Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext}; use gpui::{ - Action, AnyElement, App, AppContext as _, AsyncWindowContext, Entity, EventEmitter, - FocusHandle, Focusable, Render, Subscription, Task, WeakEntity, actions, + Action, AnyElement, App, AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, Render, + Subscription, Task, WeakEntity, actions, }; -use language::{Anchor, Buffer, BufferId, Capability, OffsetRangeExt}; -use multi_buffer::{MultiBuffer, PathKey}; +use language::Capability; +use multi_buffer::MultiBuffer; use project::{ - ConflictSet, Project, ProjectPath, + Project, ProjectPath, git_store::{ Repository, - branch_diff::{self, BranchDiffEvent, DiffBase}, + diff_buffer_list::{self, DiffBase}, }, }; -use settings::{GitPanelGroupBy, GitPanelSortBy, Settings, SettingsStore}; +use schemars::JsonSchema; +use serde::Deserialize; use std::any::{Any, TypeId}; -use std::collections::BTreeMap; use std::sync::Arc; -use theme::ActiveTheme; -use ui::{ - CommonAnimationExt as _, DiffStat, Divider, KeyBinding, PopoverMenu, Tooltip, prelude::*, -}; -use util::{ResultExt as _, rel_path::RelPath}; +use ui::{DiffStat, Divider, Tooltip, prelude::*}; use workspace::{ - CloseActiveItem, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, - ToolbarItemView, Workspace, + ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, + Workspace, item::{Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams}, - notifications::NotifyTaskExt, searchable::SearchableItemHandle, }; -use zed_actions::agent::ReviewBranchDiff; -use ztracing::instrument; +use zed_actions::git as git_actions; actions!( git, @@ -59,9 +44,6 @@ actions!( Diff, /// Adds files to the git staging area. Add, - /// Shows the diff between the working directory and your default - /// branch (typically main or master). - BranchDiff, /// Opens a new agent thread with the branch diff for review. ReviewDiff, LeaderAndFollower, @@ -70,32 +52,37 @@ actions!( ] ); -struct BufferSubscriptions { - _diff: Entity, - _diff_subscription: Subscription, - _conflict_set: Entity, - _conflict_set_subscription: Subscription, -} +/// Shows the diff between the working directory and your default +/// branch (typically main or master). +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] +#[action(namespace = git, name = "BranchDiff")] +pub(crate) struct DeployBranchDiff; pub struct ProjectDiff { project: Entity, - multibuffer: Entity, - branch_diff: Entity, - editor: Entity, - buffer_subscriptions: HashMap, BufferSubscriptions>, workspace: WeakEntity, - focus_handle: FocusHandle, - pending_scroll: Option, - review_comment_count: usize, - _task: Task>, - _subscription: Subscription, + diff: Entity, + _diff_observation: Subscription, } impl ProjectDiff { pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) { workspace.register_action(Self::deploy); - workspace.register_action(Self::deploy_branch_diff); - workspace.register_action(Self::compare_with_branch); + workspace.register_action( + |workspace, _: &git_actions::ViewUncommittedChanges, window, cx| { + Self::deploy_at(workspace, None, window, cx); + }, + ); + workspace.register_action( + |workspace, _: &git_actions::ViewUnstagedChanges, window, cx| { + UnstagedDiff::deploy_at(workspace, None, window, cx); + }, + ); + workspace.register_action( + |workspace, _: &git_actions::ViewStagedChanges, window, cx| { + StagedDiff::deploy_at(workspace, None, window, cx); + }, + ); workspace.register_action(|workspace, _: &Add, window, cx| { Self::deploy(workspace, &Diff, window, cx); }); @@ -111,216 +98,6 @@ impl ProjectDiff { Self::deploy_at(workspace, None, window, cx) } - fn deploy_branch_diff( - workspace: &mut Workspace, - _: &BranchDiff, - window: &mut Window, - cx: &mut Context, - ) { - telemetry::event!("Git Branch Diff Opened"); - let project = workspace.project().clone(); - let Some(intended_repo) = project.read(cx).active_repository(cx) else { - let workspace = cx.entity().downgrade(); - window - .spawn(cx, async |_cx| { - let result: Result<()> = Err(anyhow!("No active repository")); - result - }) - .detach_and_notify_err(workspace, window, cx); - return; - }; - - let default_branch = intended_repo.update(cx, |repo, _| repo.default_branch(true)); - let workspace = cx.entity(); - let workspace_weak = workspace.downgrade(); - window - .spawn(cx, async move |cx| { - let base_ref = default_branch - .await?? - .context("Could not determine default branch")?; - - workspace.update_in(cx, |workspace, window, cx| { - Self::deploy_branch_diff_with_base_ref( - workspace, - project, - intended_repo, - base_ref, - window, - cx, - ); - })?; - - anyhow::Ok(()) - }) - .detach_and_notify_err(workspace_weak, window, cx); - } - - fn compare_with_branch( - workspace: &mut Workspace, - _: &CompareWithBranch, - window: &mut Window, - cx: &mut Context, - ) { - let project = workspace.project().clone(); - let Some(repository) = project.read(cx).active_repository(cx) else { - let workspace = cx.entity().downgrade(); - window - .spawn(cx, async |_cx| { - let result: Result<()> = Err(anyhow!("No active repository")); - result - }) - .detach_and_notify_err(workspace, window, cx); - return; - }; - let selected_branch = workspace.active_item_as::(cx).and_then(|item| { - match item.read(cx).diff_base(cx) { - DiffBase::Merge { base_ref } => Some(base_ref.clone()), - DiffBase::Head => None, - } - }); - let workspace_handle = workspace.weak_handle(); - let on_select = Arc::new({ - let repository = repository.clone(); - let workspace = workspace_handle.clone(); - move |branch: git::repository::Branch, window: &mut Window, cx: &mut App| { - let base_ref: SharedString = branch.name().to_owned().into(); - workspace - .update(cx, |workspace, cx| { - Self::deploy_branch_diff_with_base_ref( - workspace, - project.clone(), - repository.clone(), - base_ref, - window, - cx, - ); - }) - .ok(); - } - }); - - workspace.toggle_modal(window, cx, |window, cx| { - branch_picker::select_modal( - workspace_handle, - Some(repository), - selected_branch, - on_select, - window, - cx, - ) - }); - } - - fn deploy_branch_diff_with_base_ref( - workspace: &mut Workspace, - project: Entity, - intended_repo: Entity, - base_ref: SharedString, - window: &mut Window, - cx: &mut Context, - ) { - let existing = workspace.items_of_type::(cx).find(|item| { - let item = item.read(cx); - matches!( - item.diff_base(cx), - DiffBase::Merge { base_ref: existing_base_ref } if existing_base_ref == &base_ref - ) - }); - if let Some(existing) = existing { - workspace.activate_item(&existing, true, true, window, cx); - - let needs_switch = existing - .read(cx) - .branch_diff - .read(cx) - .repo() - .map_or(true, |current| { - current.read(cx).id != intended_repo.read(cx).id - }); - - if needs_switch { - existing.update(cx, |project_diff, cx| { - project_diff.branch_diff.update(cx, |branch_diff, cx| { - branch_diff.set_repo(Some(intended_repo), cx); - }); - }); - } - - return; - } - - let workspace = cx.entity(); - let workspace_weak = workspace.downgrade(); - window - .spawn(cx, async move |cx| { - let this = cx - .update(|window, cx| { - Self::new_with_branch_base( - project, - workspace.clone(), - base_ref, - intended_repo, - window, - cx, - ) - })? - .await?; - workspace - .update_in(cx, |workspace, window, cx| { - workspace.add_item_to_active_pane(Box::new(this), None, true, window, cx); - }) - .ok(); - anyhow::Ok(()) - }) - .detach_and_notify_err(workspace_weak, window, cx); - } - - fn review_diff(&mut self, _: &ReviewDiff, window: &mut Window, cx: &mut Context) { - let diff_base = self.diff_base(cx).clone(); - let DiffBase::Merge { base_ref } = diff_base else { - return; - }; - - let Some(repo) = self.branch_diff.read(cx).repo().cloned() else { - return; - }; - - let diff_receiver = repo.update(cx, |repo, cx| { - repo.diff( - DiffType::MergeBase { - base_ref: base_ref.clone(), - }, - cx, - ) - }); - - let workspace = self.workspace.clone(); - - window - .spawn(cx, { - let workspace = workspace.clone(); - async move |cx| { - let diff_text = diff_receiver.await??; - - if let Some(workspace) = workspace.upgrade() { - workspace.update_in(cx, |_workspace, window, cx| { - window.dispatch_action( - ReviewBranchDiff { - diff_text: diff_text.into(), - base_ref, - } - .boxed_clone(), - cx, - ); - })?; - } - - anyhow::Ok(()) - } - }) - .detach_and_notify_err(workspace, window, cx); - } - pub fn deploy_at( workspace: &mut Workspace, entry: Option, @@ -337,9 +114,7 @@ impl ProjectDiff { ); let intended_repo = workspace.project().read(cx).active_repository(cx); - let existing = workspace - .items_of_type::(cx) - .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head)); + let existing = workspace.items_of_type::(cx).next(); let project_diff = if let Some(existing) = existing { existing.update(cx, |project_diff, cx| { project_diff.move_to_beginning(window, cx); @@ -364,15 +139,11 @@ impl ProjectDiff { if let Some(intended) = &intended_repo { let needs_switch = project_diff .read(cx) - .branch_diff - .read(cx) - .repo() + .repo(cx) .map_or(true, |current| current.read(cx).id != intended.read(cx).id); if needs_switch { project_diff.update(cx, |project_diff, cx| { - project_diff.branch_diff.update(cx, |branch_diff, cx| { - branch_diff.set_repo(Some(intended.clone()), cx); - }); + project_diff.set_repo(Some(intended.clone()), cx); }); } } @@ -391,9 +162,7 @@ impl ProjectDiff { cx: &mut Context, ) { telemetry::event!("Git Diff Opened", source = "Agent Panel"); - let existing = workspace - .items_of_type::(cx) - .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head)); + let existing = workspace.items_of_type::(cx).next(); let project_diff = if let Some(existing) = existing { workspace.activate_item(&existing, true, true, window, cx); existing @@ -416,71 +185,7 @@ impl ProjectDiff { } pub fn autoscroll(&self, cx: &mut Context) { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |editor, cx| { - editor.request_autoscroll(Autoscroll::fit(), cx); - }) - }) - } - - #[cfg(test)] - #[allow(dead_code)] - fn new_with_default_branch( - project: Entity, - workspace: Entity, - window: &mut Window, - cx: &mut App, - ) -> Task>> { - let Some(repo) = project.read(cx).git_store().read(cx).active_repository() else { - return Task::ready(Err(anyhow!("No active repository"))); - }; - let main_branch = repo.update(cx, |repo, _| repo.default_branch(true)); - window.spawn(cx, async move |cx| { - let main_branch = main_branch - .await?? - .context("Could not determine default branch")?; - - let branch_diff = cx.new_window_entity(|window, cx| { - let mut branch_diff = branch_diff::BranchDiff::new( - DiffBase::Merge { - base_ref: main_branch, - }, - project.clone(), - window, - cx, - ); - branch_diff.set_repo(Some(repo.clone()), cx); - branch_diff - })?; - cx.new_window_entity(|window, cx| { - Self::new_impl(branch_diff, project, workspace, window, cx) - }) - }) - } - - fn new_with_branch_base( - project: Entity, - workspace: Entity, - base_ref: SharedString, - repo: Entity, - window: &mut Window, - cx: &mut App, - ) -> Task>> { - window.spawn(cx, async move |cx| { - let branch_diff = cx.new_window_entity(|window, cx| { - let mut branch_diff = branch_diff::BranchDiff::new( - DiffBase::Merge { base_ref }, - project.clone(), - window, - cx, - ); - branch_diff.set_repo(Some(repo.clone()), cx); - branch_diff - })?; - cx.new_window_entity(|window, cx| { - Self::new_impl(branch_diff, project, workspace, window, cx) - }) - }) + self.diff.update(cx, |diff, cx| diff.autoscroll(cx)); } fn new( @@ -489,142 +194,69 @@ impl ProjectDiff { window: &mut Window, cx: &mut Context, ) -> Self { - let branch_diff = - cx.new(|cx| branch_diff::BranchDiff::new(DiffBase::Head, project.clone(), window, cx)); + let branch_diff = cx.new(|cx| { + diff_buffer_list::DiffBufferList::new(DiffBase::Head, project.clone(), window, cx) + }); Self::new_impl(branch_diff, project, workspace, window, cx) } fn new_impl( - branch_diff: Entity, + branch_diff: Entity, project: Entity, workspace: Entity, window: &mut Window, cx: &mut Context, ) -> Self { - let focus_handle = cx.focus_handle(); - let multibuffer = cx.new(|cx| { - let mut multibuffer = MultiBuffer::new(Capability::ReadWrite); - multibuffer.set_all_diff_hunks_expanded(cx); - multibuffer - }); - - let editor = cx.new(|cx| { - let diff_display_editor = SplittableEditor::new( - EditorSettings::get_global(cx).diff_view_style, - multibuffer.clone(), + let workspace_handle = workspace.downgrade(); + let diff = cx.new(|cx| { + DiffMultibuffer::new( + branch_diff, + Capability::ReadWrite, + "No uncommitted changes", + move |editor, cx| { + editor.set_diff_hunk_delegate(Some(Arc::new(UncommittedDiffHunkDelegate)), cx); + editor.rhs_editor().update(cx, |rhs_editor, _cx| { + rhs_editor.set_read_only(false); + rhs_editor.register_addon(GitPanelAddon { + workspace: workspace_handle, + }); + }); + }, project.clone(), workspace.clone(), window, cx, - ); - match branch_diff.read(cx).diff_base() { - DiffBase::Head => {} - DiffBase::Merge { .. } => diff_display_editor.disable_diff_hunk_controls(cx), - } - diff_display_editor.rhs_editor().update(cx, |editor, cx| { - editor.set_show_diff_review_button(true, cx); - - match branch_diff.read(cx).diff_base() { - DiffBase::Head => { - editor.register_addon(GitPanelAddon { - workspace: workspace.downgrade(), - }); - } - DiffBase::Merge { .. } => { - editor.register_addon(BranchDiffAddon { - branch_diff: branch_diff.clone(), - }); - } - } - }); - diff_display_editor - }); - let editor_subscription = cx.subscribe_in(&editor, window, Self::handle_editor_event); - - let primary_editor = editor.read(cx).rhs_editor().clone(); - let review_comment_subscription = - cx.subscribe(&primary_editor, |this, _editor, event: &EditorEvent, cx| { - if let EditorEvent::ReviewCommentsChanged { total_count } = event { - this.review_comment_count = *total_count; - cx.notify(); - } - }); - - let branch_diff_subscription = cx.subscribe_in( - &branch_diff, - window, - move |this, _git_store, event, window, cx| match event { - BranchDiffEvent::FileListChanged => { - this._task = window.spawn(cx, { - let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await - }) - } - BranchDiffEvent::DiffBaseChanged => { - this.pending_scroll.take(); - this._task = window.spawn(cx, { - let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await - }) - } - }, - ); - - let mut was_sort_by = GitPanelSettings::get_global(cx).sort_by; - let mut was_group_by = GitPanelSettings::get_global(cx).group_by; - let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view; - let mut was_collapse_untracked_diff = - GitPanelSettings::get_global(cx).collapse_untracked_diff; - cx.observe_global_in::(window, move |this, window, cx| { - let settings = GitPanelSettings::get_global(cx); - let sort_by = settings.sort_by; - let group_by = settings.group_by; - let tree_view = settings.tree_view; - let is_collapse_untracked_diff = settings.collapse_untracked_diff; - if sort_by != was_sort_by - || group_by != was_group_by - || tree_view != was_tree_view - || is_collapse_untracked_diff != was_collapse_untracked_diff - { - this._task = { - window.spawn(cx, { - let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await - }) - } - } - was_sort_by = sort_by; - was_group_by = group_by; - was_tree_view = tree_view; - was_collapse_untracked_diff = is_collapse_untracked_diff; - }) - .detach(); - - let task = window.spawn(cx, { - let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await + ) }); + Self::from_diff(diff, project, workspace, cx) + } + fn from_diff( + diff: Entity, + project: Entity, + workspace: Entity, + cx: &mut Context, + ) -> Self { + let observation = cx.observe(&diff, |_, _, cx| cx.notify()); Self { project, workspace: workspace.downgrade(), - branch_diff, - focus_handle, - editor, - multibuffer, - buffer_subscriptions: Default::default(), - pending_scroll: None, - review_comment_count: 0, - _task: task, - _subscription: Subscription::join( - branch_diff_subscription, - Subscription::join(editor_subscription, review_comment_subscription), - ), + diff, + _diff_observation: observation, } } pub fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase { - self.branch_diff.read(cx).diff_base() + self.diff.read(cx).diff_base(cx) + } + + pub(crate) fn repo(&self, cx: &App) -> Option> { + self.diff.read(cx).repo(cx) + } + + pub(crate) fn set_repo(&mut self, repo: Option>, cx: &mut Context) { + self.diff + .update(cx, |diff, cx| diff.set_repo(repo.clone(), cx)); } pub fn move_to_entry( @@ -633,13 +265,8 @@ impl ProjectDiff { window: &mut Window, cx: &mut Context, ) { - let Some(git_repo) = self.branch_diff.read(cx).repo() else { - return; - }; - let repo = git_repo.read(cx); - let path_key = project_diff_path_key(repo, &entry.repo_path, entry.status, cx); - - self.move_to_path(path_key, window, cx) + self.diff + .update(cx, |diff, cx| diff.move_to_entry(entry, window, cx)); } pub fn move_to_project_path( @@ -648,91 +275,42 @@ impl ProjectDiff { window: &mut Window, cx: &mut Context, ) { - let Some(git_repo) = self.branch_diff.read(cx).repo() else { - return; - }; - let Some(repo_path) = git_repo - .read(cx) - .project_path_to_repo_path(project_path, cx) - else { - return; - }; - let status = git_repo - .read(cx) - .status_for_path(&repo_path) - .map(|entry| entry.status) - .unwrap_or(FileStatus::Untracked); - let path_key = project_diff_path_key(&git_repo.read(cx), &repo_path, status, cx); - self.move_to_path(path_key, window, cx) - } - - fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context) { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |editor, cx| { - editor.change_selections(Default::default(), window, cx, |s| { - s.select_ranges(vec![multi_buffer::Anchor::Min..multi_buffer::Anchor::Min]); - }); - }); + self.diff.update(cx, |diff, cx| { + diff.move_to_project_path(project_path, window, cx) }); } - fn move_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context) { - if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |editor, cx| { - editor.change_selections( - SelectionEffects::scroll(Autoscroll::focused()), - window, - cx, - |s| { - s.select_ranges([position..position]); - }, - ) - }) - }); - } else { - self.pending_scroll = Some(path_key); - } + fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context) { + self.diff + .update(cx, |diff, cx| diff.move_to_beginning(window, cx)); } pub fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) { - self.multibuffer.read(cx).snapshot(cx).total_changed_lines() + self.diff.read(cx).calculate_changed_lines(cx) } /// Returns the total count of review comments across all hunks/files. - pub fn total_review_comment_count(&self) -> usize { - self.review_comment_count + pub fn total_review_comment_count(&self, cx: &App) -> usize { + self.diff.read(cx).total_review_comment_count() } - /// Returns a reference to the splittable editor. - pub fn editor(&self) -> &Entity { - &self.editor + /// Returns the splittable editor of the currently-shown diff view. + pub fn editor(&self, cx: &App) -> Entity { + self.diff.read(cx).editor().clone() + } + + /// Returns the multibuffer of the currently-shown diff view. + pub fn multibuffer(&self, cx: &App) -> Entity { + self.diff.read(cx).multibuffer().clone() } fn button_states(&self, cx: &App) -> ButtonStates { - let editor = self.editor.read(cx).rhs_editor().read(cx); - let snapshot = self.multibuffer.read(cx).snapshot(cx); + let diff = self.diff.read(cx); + let editor = diff.editor().read(cx).rhs_editor().clone(); + let editor = editor.read(cx); + let snapshot = diff.multibuffer().read(cx).snapshot(cx); let prev_next = snapshot.diff_hunks().nth(1).is_some(); - let mut selection = true; - - let mut ranges = editor - .selections - .disjoint_anchor_ranges() - .collect::>(); - if !ranges.iter().any(|range| range.start != range.end) { - selection = false; - let anchor = editor.selections.newest_anchor().head(); - if let Some((_, excerpt_range)) = snapshot.excerpt_containing(anchor..anchor) - && let Some(range) = snapshot - .anchor_in_buffer(excerpt_range.context.start) - .zip(snapshot.anchor_in_buffer(excerpt_range.context.end)) - .map(|(start, end)| start..end) - { - ranges = vec![range]; - } else { - ranges = Vec::default(); - }; - } + let (selection, ranges) = diff.selected_ranges(cx); let mut has_staged_hunks = false; let mut has_unstaged_hunks = false; for hunk in editor.diff_hunks_in_ranges(&ranges, &snapshot) { @@ -773,448 +351,31 @@ impl ProjectDiff { } } - fn handle_editor_event( - &mut self, - editor: &Entity, - event: &EditorEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - EditorEvent::SelectionsChanged { local: true } => { - let Some(project_path) = self.active_project_path(cx) else { - return; - }; - self.workspace - .update(cx, |workspace, cx| { - if let Some(git_panel) = workspace.panel::(cx) { - git_panel.update(cx, |git_panel, cx| { - git_panel.select_entry_by_path(project_path, window, cx) - }) - } - }) - .ok(); - } - EditorEvent::Saved => { - self._task = - cx.spawn_in(window, async move |this, cx| Self::refresh(this, cx).await); - } - _ => {} - } - if editor.focus_handle(cx).contains_focused(window, cx) - && self.multibuffer.read(cx).is_empty() - { - self.focus_handle.focus(window, cx) - } - } - - #[instrument(skip_all)] - fn register_buffer( - &mut self, - path_key: PathKey, - file_status: FileStatus, - buffer: Entity, - diff: Entity, - conflict_set: Entity, - window: &mut Window, - cx: &mut Context, - ) -> Option { - let diff_subscription = cx.subscribe_in(&diff, window, { - let path_key = path_key.clone(); - let buffer = buffer.clone(); - let diff = diff.clone(); - let conflict_set = conflict_set.clone(); - move |this, _, event, window, cx| match event { - buffer_diff::BufferDiffEvent::DiffChanged(_) => { - this.buffer_ranges_changed( - path_key.clone(), - file_status, - buffer.clone(), - diff.clone(), - conflict_set.clone(), - window, - cx, - ); - } - buffer_diff::BufferDiffEvent::BaseTextChanged - | buffer_diff::BufferDiffEvent::HunksStagedOrUnstaged(_) => {} - } - }); - let conflict_set_subscription = cx.subscribe_in(&conflict_set, window, { - let path_key = path_key.clone(); - let buffer = buffer.clone(); - let diff = diff.clone(); - let conflict_set = conflict_set.clone(); - move |this, _, _, window, cx| { - this.buffer_ranges_changed( - path_key.clone(), - file_status, - buffer.clone(), - diff.clone(), - conflict_set.clone(), - window, - cx, - ) - } - }); - self.buffer_subscriptions.insert( - path_key.path.clone(), - BufferSubscriptions { - _diff: diff.clone(), - _diff_subscription: diff_subscription, - _conflict_set: conflict_set.clone(), - _conflict_set_subscription: conflict_set_subscription, - }, - ); - - let snapshot = buffer.read(cx).snapshot(); - let diff_snapshot = diff.read(cx).snapshot(cx); - - let excerpt_ranges = { - let diff_hunk_ranges = diff_snapshot - .hunks_intersecting_range( - Anchor::min_max_range_for_buffer(snapshot.remote_id()), - &snapshot, - ) - .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot)); - let conflicts = conflict_set.read(cx).snapshot(); - let mut conflicts = conflicts - .conflicts - .iter() - .map(|conflict| conflict.range.to_point(&snapshot)) - .peekable(); - - if conflicts.peek().is_some() { - conflicts.collect::>() - } else { - diff_hunk_ranges.collect() - } - }; - - let buffer_id = snapshot.text.remote_id(); - let mut needs_fold = false; - - let (was_empty, is_excerpt_newly_added) = self.editor.update(cx, |editor, cx| { - let was_empty = editor.rhs_editor().read(cx).buffer().read(cx).is_empty(); - let is_newly_added = editor.update_excerpts_for_path( - path_key.clone(), - buffer, - excerpt_ranges, - multibuffer_context_lines(cx), - diff, - cx, - ); - editor.rhs_editor().update(cx, |editor, cx| { - conflict_view::buffer_ranges_updated(editor, conflict_set, cx); - }); - (was_empty, is_newly_added) - }); - - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |editor, cx| { - if was_empty { - editor.change_selections( - SelectionEffects::no_scroll(), - window, - cx, - |selections| { - selections.select_ranges([ - multi_buffer::Anchor::Min..multi_buffer::Anchor::Min - ]) - }, - ); - } - if is_excerpt_newly_added - && (file_status.is_deleted() - || (file_status.is_untracked() - && GitPanelSettings::get_global(cx).collapse_untracked_diff)) - { - needs_fold = true; - } - }) - }); - - if self.multibuffer.read(cx).is_empty() - && self - .editor - .read(cx) - .focus_handle(cx) - .contains_focused(window, cx) - { - self.focus_handle.focus(window, cx); - } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() { - self.editor.update(cx, |editor, cx| { - editor.focus_handle(cx).focus(window, cx); - }); - } - if self.pending_scroll.as_ref() == Some(&path_key) { - self.move_to_path(path_key, window, cx); - } - - needs_fold.then_some(buffer_id) - } - - fn buffer_ranges_changed( - &mut self, - path_key: PathKey, - file_status: FileStatus, - buffer: Entity, - diff: Entity, - conflict_set: Entity, - window: &mut Window, - cx: &mut Context, - ) { - if buffer.read(cx).is_dirty() { - return; - } - self.register_buffer( - path_key, - file_status, - buffer, - diff, - conflict_set, - window, - cx, - ); - } - - #[instrument(skip(this, cx))] - pub async fn refresh(this: WeakEntity, cx: &mut AsyncWindowContext) -> Result<()> { - let entries = this.update(cx, |this, cx| { - let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| { - let load_buffers = branch_diff.load_buffers(cx); - (branch_diff.repo().cloned(), load_buffers) - }); - let mut previous_paths = this - .multibuffer - .read(cx) - .snapshot(cx) - .buffers_with_paths() - .map(|(buffer_snapshot, path_key)| (path_key.clone(), buffer_snapshot.remote_id())) - .collect::>(); - - let mut entries = BTreeMap::new(); - if let Some(repo) = repo { - let repo = repo.read(cx); - for diff_buffer in buffers_to_load { - let path_key = project_diff_path_key( - &repo, - &diff_buffer.repo_path, - diff_buffer.file_status, - cx, - ); - previous_paths.remove(&path_key); - entries.insert(path_key, diff_buffer); - } - } - - this.editor.update(cx, |editor, cx| { - for (path, buffer_id) in previous_paths { - this.buffer_subscriptions.remove(&path.path); - editor.rhs_editor().update(cx, |editor, cx| { - conflict_view::buffers_removed(editor, &[buffer_id], cx); - }); - let _span = ztracing::info_span!("remove_excerpts_for_path"); - _span.enter(); - editor.remove_excerpts_for_path(path, cx); - } - }); - - entries - })?; - - let mut buffers_to_fold = Vec::new(); - - for (path_key, entry) in entries { - if let Some((buffer, diff, conflict_set)) = entry.load.await.log_err() { - // We might be lagging behind enough that all future entry.load futures are no longer pending. - // If that is the case, this task will never yield, starving the foreground thread of execution time. - yield_now().await; - cx.update(|window, cx| { - this.update(cx, |this, cx| { - if let Some(buffer_id) = this.register_buffer( - path_key, - entry.file_status, - buffer, - diff, - conflict_set, - window, - cx, - ) { - buffers_to_fold.push(buffer_id); - } - }) - .ok(); - })?; - } - } - this.update(cx, |this, cx| { - if !buffers_to_fold.is_empty() { - this.editor.update(cx, |editor, cx| { - editor - .rhs_editor() - .update(cx, |editor, cx| editor.fold_buffers(buffers_to_fold, cx)); - }); - } - this.pending_scroll.take(); - cx.notify(); - })?; - - Ok(()) - } - #[cfg(any(test, feature = "test-support"))] pub fn excerpt_paths(&self, cx: &App) -> Vec> { - let snapshot = self - .editor() - .read(cx) - .rhs_editor() - .read(cx) - .buffer() - .read(cx) - .snapshot(cx); - snapshot - .excerpts() - .map(|excerpt| { - snapshot - .path_for_buffer(excerpt.context.start.buffer_id) - .unwrap() - .path - .clone() - }) - .collect() + self.diff.read(cx).excerpt_paths(cx) } - /// Returns the real (worktree-relative) path of each excerpted buffer, in - /// the order the excerpts appear in the multibuffer. Unlike - /// [`Self::excerpt_paths`], this resolves the buffer's actual `File` rather - /// than the (possibly synthetic) `PathKey` path used for sorting. #[cfg(any(test, feature = "test-support"))] pub fn excerpt_file_paths(&self, cx: &App) -> Vec { - let multibuffer = self - .editor() - .read(cx) - .rhs_editor() - .read(cx) - .buffer() - .clone(); - let snapshot = multibuffer.read(cx).snapshot(cx); - let mut result = Vec::new(); - let mut last_buffer_id = None; - for excerpt in snapshot.excerpts() { - let buffer_id = excerpt.context.start.buffer_id; - if last_buffer_id == Some(buffer_id) { - continue; - } - last_buffer_id = Some(buffer_id); - if let Some(buffer) = multibuffer.read(cx).buffer(buffer_id) - && let Some(file) = buffer.read(cx).file() - { - result.push(file.path().as_unix_str().to_string()); - } - } - result + self.diff.read(cx).excerpt_file_paths(cx) } } -const CONFLICT_SORT_PREFIX: u64 = 1; -const TRACKED_SORT_PREFIX: u64 = 2; -const NEW_SORT_PREFIX: u64 = 3; - -/// Computes a stable [`PathKey`] for a buffer in the project diff. -/// -/// The key is an intrinsic function of the file's own repo path and status; it -/// never depends on which other buffers happen to be present in the -/// multibuffer. This is required because the multibuffer uses the path key both -/// to order excerpts and to identify which excerpts belong to a given buffer, so -/// a key that shifted as files were added or removed would break that identity. -/// -/// Status grouping is encoded in the `sort_prefix`, and the within-group order -/// is encoded in the (possibly synthetic) path so that `PathKey`'s natural -/// ordering reproduces the git panel's order. The path here is only ever used -/// for sorting and multibuffer identity; the path shown in the UI comes from the -/// buffer's own `File`. -fn project_diff_path_key( - repo: &Repository, - repo_path: &RepoPath, - status: FileStatus, - cx: &App, -) -> PathKey { - let settings = GitPanelSettings::get_global(cx); - let sort_prefix = if settings.group_by != GitPanelGroupBy::Status { - TRACKED_SORT_PREFIX - } else if repo.had_conflict_on_last_merge_head_change(repo_path) { - CONFLICT_SORT_PREFIX - } else if status.is_created() { - NEW_SORT_PREFIX - } else { - TRACKED_SORT_PREFIX - }; - let path = project_diff_sort_path(repo_path, settings.tree_view, settings.sort_by); - PathKey::with_sort_prefix(sort_prefix, path) -} - -fn project_diff_sort_path( - repo_path: &RelPath, - tree_view: bool, - sort_by: GitPanelSortBy, -) -> Arc { - if tree_view { - tree_sort_path(repo_path) - } else { - match sort_by { - GitPanelSortBy::Path => repo_path.into_arc(), - GitPanelSortBy::Name => name_sort_path(repo_path), - } - } -} - -/// Builds a synthetic path that sorts by file name first, falling back to the -/// full path to keep the key unique per file. -fn name_sort_path(repo_path: &RelPath) -> Arc { - let Some(file_name) = repo_path.file_name() else { - return repo_path.into_arc(); - }; - let synthetic = format!("{}/{}", file_name, repo_path.as_unix_str()); - RelPath::unix(&synthetic) - .map(|path| path.into_arc()) - .unwrap_or_else(|_| repo_path.into_arc()) -} - -/// Builds a synthetic path whose natural component-wise ordering reproduces a -/// folder-first tree order. Each directory component is prefixed with a NUL -/// byte, which can never appear in a real path component and sorts before every -/// printable character, so at each level directories sort before files. -fn tree_sort_path(repo_path: &RelPath) -> Arc { - let components: Vec<&str> = repo_path.components().collect(); - if components.len() <= 1 { - return repo_path.into_arc(); - } - let last = components.len() - 1; - let mut synthetic = String::new(); - for (index, component) in components.into_iter().enumerate() { - if index > 0 { - synthetic.push('/'); - } - if index < last { - synthetic.push('\0'); - } - synthetic.push_str(component); - } - RelPath::unix(&synthetic) - .map(|path| path.into_arc()) - .unwrap_or_else(|_| repo_path.into_arc()) +struct ButtonStates { + stage: bool, + unstage: bool, + prev_next: bool, + selection: bool, + stage_all: bool, + unstage_all: bool, } impl EventEmitter for ProjectDiff {} impl Focusable for ProjectDiff { fn focus_handle(&self, cx: &App) -> FocusHandle { - if self.multibuffer.read(cx).is_empty() { - self.focus_handle.clone() - } else { - self.editor.focus_handle(cx) - } + self.diff.read(cx).focus_handle(cx) } } @@ -1230,11 +391,8 @@ impl Item for ProjectDiff { } fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |primary_editor, cx| { - primary_editor.deactivated(window, cx); - }) - }); + self.diff + .update(cx, |diff, cx| diff.deactivated(window, cx)); } fn navigate( @@ -1243,18 +401,12 @@ impl Item for ProjectDiff { window: &mut Window, cx: &mut Context, ) -> bool { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |primary_editor, cx| { - primary_editor.navigate(data, window, cx) - }) - }) + self.diff + .update(cx, |diff, cx| diff.navigate(data, window, cx)) } fn tab_tooltip_text(&self, cx: &App) -> Option { - match self.diff_base(cx) { - DiffBase::Head => Some("Project Diff".into()), - DiffBase::Merge { .. } => Some("Branch Diff".into()), - } + Some(self.tab_content_text(0, cx)) } fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { @@ -1267,19 +419,16 @@ impl Item for ProjectDiff { .into_any_element() } - fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString { - match self.branch_diff.read(cx).diff_base() { - DiffBase::Head => "Uncommitted Changes".into(), - DiffBase::Merge { base_ref } => format!("Changes since {}", base_ref).into(), - } + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Uncommitted Changes".into() } fn telemetry_event_text(&self) -> Option<&'static str> { Some("Project Diff Opened") } - fn as_searchable(&self, _: &Entity, _cx: &App) -> Option> { - Some(Box::new(self.editor.clone())) + fn as_searchable(&self, _: &Entity, cx: &App) -> Option> { + Some(Box::new(self.diff.read(cx).editor().clone())) } fn for_each_project_item( @@ -1287,26 +436,11 @@ impl Item for ProjectDiff { cx: &App, f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), ) { - self.editor - .read(cx) - .rhs_editor() - .read(cx) - .for_each_project_item(cx, f) + self.diff.read(cx).for_each_project_item(cx, f) } fn active_project_path(&self, cx: &App) -> Option { - let editor = self.editor.read(cx).focused_editor().read(cx); - let multibuffer = editor.buffer().read(cx); - let position = editor.selections.newest_anchor().head(); - let snapshot = multibuffer.snapshot(cx); - let (text_anchor, _) = snapshot.anchor_to_buffer_anchor(position)?; - let buffer = multibuffer.buffer(text_anchor.buffer_id)?; - - let file = buffer.read(cx).file()?; - Some(ProjectPath { - worktree_id: file.worktree_id(cx), - path: file.path().clone(), - }) + self.diff.read(cx).active_project_path(cx) } fn set_nav_history( @@ -1315,11 +449,8 @@ impl Item for ProjectDiff { _: &mut Window, cx: &mut Context, ) { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |primary_editor, _| { - primary_editor.set_nav_history(Some(nav_history)); - }) - }); + self.diff + .update(cx, |diff, cx| diff.set_nav_history(nav_history, cx)); } fn can_split(&self) -> bool { @@ -1344,11 +475,11 @@ impl Item for ProjectDiff { } fn is_dirty(&self, cx: &App) -> bool { - self.multibuffer.read(cx).is_dirty(cx) + self.diff.read(cx).is_dirty(cx) } fn has_conflict(&self, cx: &App) -> bool { - self.multibuffer.read(cx).has_conflict(cx) + self.diff.read(cx).has_conflict(cx) } fn can_save(&self, _: &App) -> bool { @@ -1362,11 +493,8 @@ impl Item for ProjectDiff { window: &mut Window, cx: &mut Context, ) -> Task> { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |primary_editor, cx| { - primary_editor.save(options, project, window, cx) - }) - }) + self.diff + .update(cx, |diff, cx| diff.save(options, project, window, cx)) } fn save_as( @@ -1385,11 +513,8 @@ impl Item for ProjectDiff { window: &mut Window, cx: &mut Context, ) -> Task> { - self.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |primary_editor, cx| { - primary_editor.reload(project, window, cx) - }) - }) + self.diff + .update(cx, |diff, cx| diff.reload(project, window, cx)) } fn act_as_type<'a>( @@ -1401,9 +526,19 @@ impl Item for ProjectDiff { if type_id == TypeId::of::() { Some(self_handle.clone().into()) } else if type_id == TypeId::of::() { - Some(self.editor.read(cx).rhs_editor().clone().into()) + Some( + self.diff + .read(cx) + .editor() + .read(cx) + .rhs_editor() + .clone() + .into(), + ) } else if type_id == TypeId::of::() { - Some(self.editor.clone().into()) + Some(self.diff.read(cx).editor().clone().into()) + } else if type_id == TypeId::of::() { + Some(self.diff.read(cx).branch_diff().clone().into()) } else { None } @@ -1415,88 +550,15 @@ impl Item for ProjectDiff { window: &mut Window, cx: &mut Context, ) { - self.editor.update(cx, |editor, cx| { - editor.added_to_workspace(workspace, window, cx) + self.diff.update(cx, |diff, cx| { + diff.added_to_workspace(workspace, window, cx) }); } } impl Render for ProjectDiff { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let is_empty = self.multibuffer.read(cx).is_empty(); - let is_loading = self.branch_diff.read(cx).is_tree_base_loading() || !self._task.is_ready(); - - let is_branch_diff_view = matches!(self.diff_base(cx), DiffBase::Merge { .. }); - - div() - .track_focus(&self.focus_handle) - .key_context(if is_empty { "EmptyPane" } else { "GitDiff" }) - .when(is_branch_diff_view, |this| { - this.on_action(cx.listener(Self::review_diff)) - }) - .bg(cx.theme().colors().editor_background) - .flex() - .items_center() - .justify_center() - .size_full() - .when(is_empty && is_loading, |el| { - let rems = TextSize::Large.rems(cx); - el.child( - Icon::new(IconName::LoadCircle) - .size(IconSize::Custom(rems)) - .color(Color::Accent) - .with_rotate_animation(3) - .into_any_element(), - ) - }) - .when(is_empty && !is_loading, |el| { - let remote_button = if let Some(panel) = self - .workspace - .upgrade() - .and_then(|workspace| workspace.read(cx).panel::(cx)) - { - panel.update(cx, |panel, cx| panel.render_remote_button(cx)) - } else { - None - }; - let keybinding_focus_handle = self.focus_handle(cx); - el.child( - v_flex() - .gap_1() - .child( - h_flex() - .justify_around() - .child(Label::new("No uncommitted changes")), - ) - .map(|el| match remote_button { - Some(button) => el.child(h_flex().justify_around().child(button)), - None => el.child( - h_flex() - .justify_around() - .child(Label::new("Remote up to date")), - ), - }) - .child( - h_flex().justify_around().mt_1().child( - Button::new("project-diff-close-button", "Close") - // .style(ButtonStyle::Transparent) - .key_binding(KeyBinding::for_action_in( - &CloseActiveItem::default(), - &keybinding_focus_handle, - cx, - )) - .on_click(move |_, window, cx| { - window.focus(&keybinding_focus_handle, cx); - window.dispatch_action( - Box::new(CloseActiveItem::default()), - cx, - ); - }), - ), - ), - ) - }) - .when(!is_empty, |el| el.child(self.editor.clone())) + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(self.diff.clone()) } } @@ -1517,46 +579,38 @@ impl SerializableItem for ProjectDiff { fn deserialize( project: Entity, workspace: WeakEntity, - workspace_id: workspace::WorkspaceId, - item_id: workspace::ItemId, + _workspace_id: workspace::WorkspaceId, + _item_id: workspace::ItemId, window: &mut Window, cx: &mut App, ) -> Task>> { - let db = persistence::ProjectDiffDb::global(cx); window.spawn(cx, async move |cx| { - let diff_base = db.get_diff_base(item_id, workspace_id)?; - - let diff = cx.update(|window, cx| { - let branch_diff = cx - .new(|cx| branch_diff::BranchDiff::new(diff_base, project.clone(), window, cx)); + cx.update(|window, cx| { + let branch_diff = cx.new(|cx| { + diff_buffer_list::DiffBufferList::new( + DiffBase::Head, + project.clone(), + window, + cx, + ) + }); let workspace = workspace.upgrade().context("workspace gone")?; anyhow::Ok( cx.new(|cx| ProjectDiff::new_impl(branch_diff, project, workspace, window, cx)), ) - })??; - - Ok(diff) + })? }) } fn serialize( &mut self, - workspace: &mut Workspace, - item_id: workspace::ItemId, - _closing: bool, - _window: &mut Window, - cx: &mut Context, + _: &mut Workspace, + _: workspace::ItemId, + _: bool, + _: &mut Window, + _: &mut Context, ) -> Option>> { - let workspace_id = workspace.database_id()?; - let diff_base = self.diff_base(cx).clone(); - - let db = persistence::ProjectDiffDb::global(cx); - Some(cx.background_spawn({ - async move { - db.save_diff_base(item_id, workspace_id, diff_base.clone()) - .await - } - })) + Some(Task::ready(Ok(()))) } fn should_serialize(&self, _: &Self::Event) -> bool { @@ -1564,14 +618,14 @@ impl SerializableItem for ProjectDiff { } } -mod persistence { +pub(crate) mod persistence { use anyhow::Context as _; use db::{ sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, sqlez_macros::sql, }; - use project::git_store::branch_diff::DiffBase; + use project::git_store::diff_buffer_list::DiffBase; use workspace::{ItemId, WorkspaceDb, WorkspaceId}; pub struct ProjectDiffDb(ThreadSafeConnection); @@ -1579,7 +633,11 @@ mod persistence { impl Domain for ProjectDiffDb { const NAME: &str = stringify!(ProjectDiffDb); - const MIGRATIONS: &[&str] = &[sql!( + // Legacy databases stored branch diffs under the "ProjectDiff" item + // kind, disambiguated by the `diff_base` column. Step 1 rewrites those + // item kinds so that each diff view owns its serialized kind. + const MIGRATIONS: &[&str] = &[ + sql!( CREATE TABLE project_diffs( workspace_id INTEGER, item_id INTEGER UNIQUE, @@ -1590,13 +648,23 @@ mod persistence { FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ) STRICT; - )]; + ), + r#" + UPDATE items SET kind = 'BranchDiff' + WHERE kind = 'ProjectDiff' AND EXISTS ( + SELECT 1 FROM project_diffs + WHERE project_diffs.item_id = items.item_id + AND project_diffs.workspace_id = items.workspace_id + AND project_diffs.diff_base LIKE '{"Merge"%' + ); + "#, + ]; } db::static_connection!(ProjectDiffDb, [WorkspaceDb]); impl ProjectDiffDb { - pub async fn save_diff_base( + pub async fn save_project_diff_base( &self, item_id: ItemId, workspace_id: WorkspaceId, @@ -1616,7 +684,7 @@ mod persistence { .await } - pub fn get_diff_base( + pub fn get_project_diff_base( &self, item_id: ItemId, workspace_id: WorkspaceId, @@ -1702,7 +770,6 @@ impl ToolbarItemView for ProjectDiffToolbar { ) -> ToolbarItemLocation { self.project_diff = active_pane_item .and_then(|item| item.act_as::(cx)) - .filter(|item| item.read(cx).diff_base(cx) == &DiffBase::Head) .map(|entity| entity.downgrade()); if self.project_diff.is_some() { ToolbarItemLocation::PrimaryRight @@ -1720,15 +787,6 @@ impl ToolbarItemView for ProjectDiffToolbar { } } -struct ButtonStates { - stage: bool, - unstage: bool, - prev_next: bool, - selection: bool, - stage_all: bool, - unstage_all: bool, -} - impl Render for ProjectDiffToolbar { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let Some(project_diff) = self.project_diff(cx) else { @@ -1736,10 +794,10 @@ impl Render for ProjectDiffToolbar { }; let focus_handle = project_diff.focus_handle(cx); let button_states = project_diff.read(cx).button_states(cx); - let review_count = project_diff.read(cx).total_review_comment_count(); + let review_count = project_diff.read(cx).total_review_comment_count(cx); let (additions, deletions) = project_diff.read(cx).calculate_changed_lines(cx); - let is_multibuffer_empty = project_diff.read(cx).multibuffer.read(cx).is_empty(); + let is_multibuffer_empty = project_diff.read(cx).multibuffer(cx).read(cx).is_empty(); h_flex() .my_neg_1() @@ -1890,7 +948,10 @@ impl Render for ProjectDiffToolbar { } } -fn render_send_review_to_agent_button(review_count: usize, focus_handle: &FocusHandle) -> Button { +pub(crate) fn render_send_review_to_agent_button( + review_count: usize, + focus_handle: &FocusHandle, +) -> Button { Button::new( "send-review", format!("Send Review to Agent ({})", review_count), @@ -1907,207 +968,19 @@ fn render_send_review_to_agent_button(review_count: usize, focus_handle: &FocusH )) } -pub struct BranchDiffToolbar { - project_diff: Option>, -} - -impl BranchDiffToolbar { - pub fn new(_cx: &mut Context) -> Self { - Self { project_diff: None } - } - - fn project_diff(&self, _: &App) -> Option> { - self.project_diff.as_ref()?.upgrade() - } - - fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context) { - if let Some(project_diff) = self.project_diff(cx) { - project_diff.focus_handle(cx).focus(window, cx); - } - let action = action.boxed_clone(); - cx.defer(move |cx| { - cx.dispatch_action(action.as_ref()); - }) - } -} - -impl EventEmitter for BranchDiffToolbar {} - -impl ToolbarItemView for BranchDiffToolbar { - fn set_active_pane_item( - &mut self, - active_pane_item: Option<&dyn ItemHandle>, - _: &mut Window, - cx: &mut Context, - ) -> ToolbarItemLocation { - self.project_diff = active_pane_item - .and_then(|item| item.act_as::(cx)) - .filter(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Merge { .. })) - .map(|entity| entity.downgrade()); - if self.project_diff.is_some() { - ToolbarItemLocation::PrimaryRight - } else { - ToolbarItemLocation::Hidden - } - } - - fn pane_focus_update( - &mut self, - _pane_focused: bool, - _window: &mut Window, - _cx: &mut Context, - ) { - } -} - -impl Render for BranchDiffToolbar { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let Some(project_diff) = self.project_diff(cx) else { - return div(); - }; - let focus_handle = project_diff.focus_handle(cx); - let review_count = project_diff.read(cx).total_review_comment_count(); - let (additions, deletions) = project_diff.read(cx).calculate_changed_lines(cx); - let diff_base = project_diff.read(cx).diff_base(cx).clone(); - let DiffBase::Merge { base_ref } = diff_base else { - return div(); - }; - let selected_base_ref = base_ref.clone(); - let base_ref_label = format!("Base: {base_ref}"); - let repository = project_diff.read(cx).branch_diff.read(cx).repo().cloned(); - let workspace = project_diff.read(cx).workspace.clone(); - let project_diff_for_picker = project_diff.downgrade(); - - let is_multibuffer_empty = project_diff.read(cx).multibuffer.read(cx).is_empty(); - let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx); - - let show_review_button = !is_multibuffer_empty && is_ai_enabled; - - h_flex() - .my_neg_1() - .py_1() - .gap_1p5() - .flex_wrap() - .justify_between() - .when(!is_multibuffer_empty, |this| { - this.child(DiffStat::new( - "branch-diff-stat", - additions as usize, - deletions as usize, - )) - }) - .child(Divider::vertical().ml_1()) - .child( - PopoverMenu::new("branch-diff-base-branch-picker") - .menu(move |window, cx| { - let project_diff = project_diff_for_picker.clone(); - let on_select = Arc::new( - move |branch: git::repository::Branch, - _window: &mut Window, - cx: &mut App| { - let base_ref: SharedString = branch.name().to_owned().into(); - project_diff - .update(cx, |project_diff, cx| { - let branch_diff = &mut project_diff.branch_diff; - branch_diff.update(cx, |branch_diff, cx| { - branch_diff - .set_diff_base(DiffBase::Merge { base_ref }, cx); - }); - cx.notify(); - }) - .ok(); - }, - ); - - Some(branch_picker::select_popover( - workspace.clone(), - repository.clone(), - Some(selected_base_ref.clone()), - on_select, - window, - cx, - )) - }) - .trigger_with_tooltip( - Button::new("branch-diff-base-branch", base_ref_label).end_icon( - Icon::new(IconName::ChevronDown) - .size(IconSize::XSmall) - .color(Color::Muted), - ), - Tooltip::text("Select Base Branch"), - ), - ) - .when(show_review_button, |this| { - let focus_handle = focus_handle.clone(); - this.child(Divider::vertical()).child( - Button::new("review-diff", "Review Diff") - .start_icon( - Icon::new(IconName::ZedAssistant) - .size(IconSize::Small) - .color(Color::Muted), - ) - .tooltip(move |_, cx| { - Tooltip::with_meta_in( - "Review Diff", - Some(&ReviewDiff), - "Send this diff for your last agent to review.", - &focus_handle, - cx, - ) - }) - .on_click(cx.listener(|this, _, window, cx| { - this.dispatch_action(&ReviewDiff, window, cx); - })), - ) - }) - .when(review_count > 0, |this| { - this.child(Divider::vertical()).child( - render_send_review_to_agent_button(review_count, &focus_handle).on_click( - cx.listener(|this, _, window, cx| { - this.dispatch_action(&SendReviewToAgent, window, cx) - }), - ), - ) - }) - } -} - -struct BranchDiffAddon { - branch_diff: Entity, -} - -impl Addon for BranchDiffAddon { - fn to_any(&self) -> &dyn std::any::Any { - self - } - - fn override_status_for_buffer_id( - &self, - buffer_id: language::BufferId, - cx: &App, - ) -> Option { - self.branch_diff - .read(cx) - .status_for_buffer_id(buffer_id, cx) - } -} - #[cfg(test)] mod tests { - use collections::HashMap; + use buffer_diff::DiffHunkSecondaryStatus; use db::indoc; use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff}; - use git::status::{TrackedStatus, UnmergedStatus, UnmergedStatusCode}; use gpui::TestAppContext; + use multi_buffer::PathKey; use project::FakeFs; use serde_json::json; use settings::{DiffViewStyle, GitPanelGroupBy, GitPanelSortBy, SettingsStore}; use std::path::Path; use unindent::Unindent as _; - use util::{ - path, - rel_path::{RelPath, rel_path}, - }; + use util::{path, rel_path::rel_path}; use workspace::MultiWorkspace; @@ -2133,6 +1006,482 @@ mod tests { }); } + use zed_actions::git as git_actions; + + use crate::project_diff::{self, ProjectDiff}; + + #[test] + fn test_legacy_branch_diff_rows_migrate_to_their_own_kind() { + use db::sqlez::{ + connection::Connection, + domain::{Domain as _, Migrator as _}, + }; + + let connection = Connection::open_memory(Some( + "test_legacy_branch_diff_rows_migrate_to_their_own_kind", + )); + connection.exec("PRAGMA foreign_keys = OFF").unwrap()().unwrap(); + workspace::WorkspaceDb::migrate(&connection).unwrap(); + connection + .migrate( + persistence::ProjectDiffDb::NAME, + &persistence::ProjectDiffDb::MIGRATIONS[..1], + &mut |_, _, _| false, + ) + .unwrap(); + + connection + .exec( + "INSERT INTO workspaces(workspace_id) VALUES (1); + INSERT INTO panes(pane_id, workspace_id, active) VALUES (1, 1, 1); + INSERT INTO items(item_id, workspace_id, pane_id, kind, position, active) VALUES + (1, 1, 1, 'ProjectDiff', 0, 1), + (2, 1, 1, 'ProjectDiff', 1, 0)", + ) + .unwrap()() + .unwrap(); + let head = serde_json::to_string(&DiffBase::Head).unwrap(); + let merge = serde_json::to_string(&DiffBase::Merge { + base_ref: "main".into(), + }) + .unwrap(); + connection + .exec_bound::<(String, String)>( + "INSERT INTO project_diffs(workspace_id, item_id, diff_base) VALUES (1, 1, ?), (1, 2, ?)", + ) + .unwrap()((head, merge)) + .unwrap(); + + persistence::ProjectDiffDb::migrate(&connection).unwrap(); + + let kinds = connection + .select::<(i64, String)>("SELECT item_id, kind FROM items ORDER BY item_id") + .unwrap()() + .unwrap(); + assert_eq!( + kinds, + [ + (1, "ProjectDiff".to_string()), + (2, "BranchDiff".to_string()) + ] + ); + } + + #[gpui::test] + async fn test_update_on_uncommit(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "README.md": "# My cool project\n".to_owned() + }), + ) + .await; + fs.set_head_and_index_for_repo( + Path::new(path!("/project/.git")), + &[("README.md", "# My cool project\n".to_owned())], + ); + let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + cx.run_until_parked(); + + let _editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("README.md")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + }); + cx.run_until_parked(); + let item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + cx.focus(&item); + let editor = item.read_with(cx, |item, cx| item.editor(cx).read(cx).rhs_editor().clone()); + + fs.set_head_and_index_for_repo( + Path::new(path!("/project/.git")), + &[( + "README.md", + "# My cool project\nDetails to come.\n".to_owned(), + )], + ); + cx.run_until_parked(); + + let mut cx = EditorTestContext::for_editor_in(editor, cx).await; + + cx.assert_excerpts_with_selections("[EXCERPT]\nˇ# My cool project\nDetails to come.\n"); + } + + #[gpui::test] + async fn test_deploy_at_respects_active_repository_selection(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project_a"), + json!({ + ".git": {}, + "a.txt": "CHANGED_A\n", + }), + ) + .await; + fs.insert_tree( + path!("/project_b"), + json!({ + ".git": {}, + "b.txt": "CHANGED_B\n", + }), + ) + .await; + + fs.set_head_and_index_for_repo( + Path::new(path!("/project_a/.git")), + &[("a.txt", "original_a\n".to_string())], + ); + fs.set_head_and_index_for_repo( + Path::new(path!("/project_b/.git")), + &[("b.txt", "original_b\n".to_string())], + ); + + let project = Project::test( + fs.clone(), + [ + Path::new(path!("/project_a")), + Path::new(path!("/project_b")), + ], + cx, + ) + .await; + + let (worktree_a_id, worktree_b_id) = project.read_with(cx, |project, cx| { + let mut worktrees: Vec<_> = project.worktrees(cx).collect(); + worktrees.sort_by_key(|w| w.read(cx).abs_path()); + (worktrees[0].read(cx).id(), worktrees[1].read(cx).id()) + }); + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + cx.run_until_parked(); + + // Select project A explicitly and open the diff. + workspace.update(cx, |workspace, cx| { + let git_store = workspace.project().read(cx).git_store().clone(); + git_store.update(cx, |git_store, cx| { + git_store.set_active_repo_for_worktree(worktree_a_id, cx); + }); + }); + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let diff_item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + let paths_a = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx)); + assert_eq!(paths_a.len(), 1); + assert_eq!(*paths_a[0], *"a.txt"); + + // Switch the explicit active repository to project B and re-run the diff action. + workspace.update(cx, |workspace, cx| { + let git_store = workspace.project().read(cx).git_store().clone(); + git_store.update(cx, |git_store, cx| { + git_store.set_active_repo_for_worktree(worktree_b_id, cx); + }); + }); + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let same_diff_item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + assert_eq!(diff_item.entity_id(), same_diff_item.entity_id()); + + let paths_b = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx)); + assert_eq!(paths_b.len(), 1); + assert_eq!(*paths_b[0], *"b.txt"); + } + + #[gpui::test] + async fn test_project_diff_actions_filter_mixed_staged_and_unstaged_hunks( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let committed_contents = r#" + fn main() { + println!("hello world"); + } + "# + .unindent(); + let staged_contents = r#" + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + let file_contents = r#" + // print goodbye + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "src": { + "main.rs": file_contents, + } + }), + ) + .await; + + fs.set_head_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", committed_contents)], + "deadbeef", + ); + fs.set_index_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", staged_contents)], + ); + + let project = Project::test(fs, [path!("/project").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()); + cx.run_until_parked(); + + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let diff_item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + let diff_editor = + diff_item.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone()); + assert_eq!( + diff_editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot) + .map(|hunk| hunk.status.secondary) + .collect::>() + }), + vec![ + DiffHunkSecondaryStatus::HasSecondaryHunk, + DiffHunkSecondaryStatus::NoSecondaryHunk, + ] + ); + + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(git_actions::ViewUnstagedChanges.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let unstaged_item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + assert_ne!(diff_item.entity_id(), unstaged_item.entity_id()); + let unstaged_editor = workspace.update(cx, |workspace, cx| { + let active_item = workspace.active_item(cx).unwrap(); + assert_eq!(active_item.tab_content_text(0, cx), "Unstaged Changes"); + active_item + .act_as::(cx) + .unwrap() + .read(cx) + .editor() + .read(cx) + .rhs_editor() + .clone() + }); + assert_eq!( + unstaged_editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot) + .map(|hunk| hunk.status.secondary) + .collect::>() + }), + vec![DiffHunkSecondaryStatus::NoSecondaryHunk] + ); + + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(git_actions::ViewUncommittedChanges.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let uncommitted_item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + assert_eq!(diff_item.entity_id(), uncommitted_item.entity_id()); + assert_eq!( + uncommitted_item.read_with(cx, |diff, cx| diff.tab_content_text(0, cx)), + "Uncommitted Changes" + ); + let uncommitted_editor = uncommitted_item + .read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone()); + assert_eq!( + uncommitted_editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot) + .map(|hunk| hunk.status.secondary) + .collect::>() + }), + vec![ + DiffHunkSecondaryStatus::HasSecondaryHunk, + DiffHunkSecondaryStatus::NoSecondaryHunk, + ] + ); + + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(git_actions::ViewStagedChanges.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let staged_editor = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap(); + let active_item = workspace.active_item(cx).unwrap(); + assert_eq!(active_item.tab_content_text(0, cx), "Staged Changes"); + active_item + .act_as::(cx) + .unwrap() + .read(cx) + .editor() + .read(cx) + .rhs_editor() + .clone() + }); + assert_eq!( + staged_editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot) + .map(|hunk| hunk.status.secondary) + .collect::>() + }), + vec![DiffHunkSecondaryStatus::NoSecondaryHunk] + ); + } + + #[gpui::test] + async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + ".git": {}, + "a.txt": "created\n", + "b.txt": "really changed\n", + "c.txt": "unchanged\n" + }), + ) + .await; + + fs.set_head_and_index_for_repo( + Path::new(path!("/a/.git")), + &[ + ("b.txt", "before\n".to_string()), + ("c.txt", "unchanged\n".to_string()), + ("d.txt", "deleted\n".to_string()), + ], + ); + + let project = Project::test(fs, [Path::new(path!("/a"))], 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()); + + cx.run_until_parked(); + + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + }); + + cx.run_until_parked(); + + let item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + cx.focus(&item); + let editor = item.read_with(cx, |item, cx| item.editor(cx).read(cx).rhs_editor().clone()); + + let mut cx = EditorTestContext::for_editor_in(editor, cx).await; + + cx.set_selections_state(indoc!( + " + before + really changed + + deleted + + ˇcreated + " + )); + + cx.dispatch_action(editor::actions::GoToPreviousHunk); + + cx.assert_excerpts_with_selections(indoc!( + " + [EXCERPT] + before + really changed + [EXCERPT] + ˇ[FOLDED] + [EXCERPT] + created + " + )); + + cx.dispatch_action(editor::actions::GoToPreviousHunk); + + cx.assert_excerpts_with_selections(indoc!( + " + [EXCERPT] + ˇbefore + really changed + [EXCERPT] + [FOLDED] + [EXCERPT] + created + " + )); + } + #[gpui::test] async fn test_save_after_restore(cx: &mut TestAppContext) { init_test(cx); @@ -2166,7 +1515,7 @@ mod tests { }); cx.run_until_parked(); - let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone()); + let editor = diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone()); assert_state_with_diff( &editor, cx, @@ -2222,12 +1571,14 @@ mod tests { cx.run_until_parked(); let editor = cx.update_window_entity(&diff, |diff, window, cx| { - diff.move_to_path( - PathKey::with_sort_prefix(2, rel_path("foo").into_arc()), - window, - cx, - ); - diff.editor.read(cx).rhs_editor().clone() + diff.diff.update(cx, |diff, cx| { + diff.move_to_path( + PathKey::with_sort_prefix(2, rel_path("foo").into_arc()), + window, + cx, + ) + }); + diff.editor(cx).read(cx).rhs_editor().clone() }); assert_state_with_diff( &editor, @@ -2243,12 +1594,14 @@ mod tests { ); let editor = cx.update_window_entity(&diff, |diff, window, cx| { - diff.move_to_path( - PathKey::with_sort_prefix(2, rel_path("bar").into_arc()), - window, - cx, - ); - diff.editor.read(cx).rhs_editor().clone() + diff.diff.update(cx, |diff, cx| { + diff.move_to_path( + PathKey::with_sort_prefix(2, rel_path("bar").into_arc()), + window, + cx, + ) + }); + diff.editor(cx).read(cx).rhs_editor().clone() }); assert_state_with_diff( &editor, @@ -2301,7 +1654,8 @@ mod tests { }); cx.run_until_parked(); - let diff_editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone()); + let diff_editor = + diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone()); assert_state_with_diff( &diff_editor, @@ -2379,334 +1733,6 @@ mod tests { ); } - use crate::project_diff::{self, ProjectDiff}; - - #[gpui::test] - async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/a"), - json!({ - ".git": {}, - "a.txt": "created\n", - "b.txt": "really changed\n", - "c.txt": "unchanged\n" - }), - ) - .await; - - fs.set_head_and_index_for_repo( - Path::new(path!("/a/.git")), - &[ - ("b.txt", "before\n".to_string()), - ("c.txt", "unchanged\n".to_string()), - ("d.txt", "deleted\n".to_string()), - ], - ); - - let project = Project::test(fs, [Path::new(path!("/a"))], 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()); - - cx.run_until_parked(); - - cx.focus(&workspace); - cx.update(|window, cx| { - window.dispatch_action(project_diff::Diff.boxed_clone(), cx); - }); - - cx.run_until_parked(); - - let item = workspace.update(cx, |workspace, cx| { - workspace.active_item_as::(cx).unwrap() - }); - cx.focus(&item); - let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone()); - - let mut cx = EditorTestContext::for_editor_in(editor, cx).await; - - cx.set_selections_state(indoc!( - " - before - really changed - - deleted - - ˇcreated - " - )); - - cx.dispatch_action(editor::actions::GoToPreviousHunk); - - cx.assert_excerpts_with_selections(indoc!( - " - [EXCERPT] - before - really changed - [EXCERPT] - ˇ[FOLDED] - [EXCERPT] - created - " - )); - - cx.dispatch_action(editor::actions::GoToPreviousHunk); - - cx.assert_excerpts_with_selections(indoc!( - " - [EXCERPT] - ˇbefore - really changed - [EXCERPT] - [FOLDED] - [EXCERPT] - created - " - )); - } - - #[gpui::test] - async fn test_excerpts_splitting_after_restoring_the_middle_excerpt(cx: &mut TestAppContext) { - init_test(cx); - - let git_contents = indoc! {r#" - #[rustfmt::skip] - fn main() { - let x = 0.0; // this line will be removed - // 1 - // 2 - // 3 - let y = 0.0; // this line will be removed - // 1 - // 2 - // 3 - let arr = [ - 0.0, // this line will be removed - 0.0, // this line will be removed - 0.0, // this line will be removed - 0.0, // this line will be removed - ]; - } - "#}; - let buffer_contents = indoc! {" - #[rustfmt::skip] - fn main() { - // 1 - // 2 - // 3 - // 1 - // 2 - // 3 - let arr = [ - ]; - } - "}; - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/a"), - json!({ - ".git": {}, - "main.rs": buffer_contents, - }), - ) - .await; - - fs.set_head_and_index_for_repo( - Path::new(path!("/a/.git")), - &[("main.rs", git_contents.to_owned())], - ); - - let project = Project::test(fs, [Path::new(path!("/a"))], 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()); - - cx.run_until_parked(); - - cx.focus(&workspace); - cx.update(|window, cx| { - window.dispatch_action(project_diff::Diff.boxed_clone(), cx); - }); - - cx.run_until_parked(); - - let item = workspace.update(cx, |workspace, cx| { - workspace.active_item_as::(cx).unwrap() - }); - cx.focus(&item); - let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone()); - - let mut cx = EditorTestContext::for_editor_in(editor, cx).await; - - cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}")); - - cx.dispatch_action(editor::actions::GoToHunk); - cx.dispatch_action(editor::actions::GoToHunk); - cx.dispatch_action(git::Restore); - cx.dispatch_action(editor::actions::MoveToBeginning); - - cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}")); - } - - #[gpui::test(iterations = 50)] - async fn test_split_diff_conflict_path_transition_with_dirty_buffer_invalid_anchor_panics( - cx: &mut TestAppContext, - ) { - init_test(cx); - - cx.update(|cx| { - cx.update_global::(|store, cx| { - store.update_user_settings(cx, |settings| { - settings.editor.diff_view_style = Some(DiffViewStyle::Split); - }); - }); - }); - - let build_conflict_text: fn(usize) -> String = |tag: usize| { - let mut lines = (0..80) - .map(|line_index| format!("line {line_index}")) - .collect::>(); - for offset in [5usize, 20, 37, 61] { - lines[offset] = format!("base-{tag}-line-{offset}"); - } - format!("{}\n", lines.join("\n")) - }; - let initial_conflict_text = build_conflict_text(0); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - ".git": {}, - "helper.txt": "same\n", - "conflict.txt": initial_conflict_text, - }), - ) - .await; - fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { - state - .refs - .insert("MERGE_HEAD".into(), "conflict-head".into()); - }) - .unwrap(); - fs.set_status_for_repo( - path!("/project/.git").as_ref(), - &[( - "conflict.txt", - FileStatus::Unmerged(UnmergedStatus { - first_head: UnmergedStatusCode::Updated, - second_head: UnmergedStatusCode::Updated, - }), - )], - ); - fs.set_merge_base_content_for_repo( - path!("/project/.git").as_ref(), - &[ - ("conflict.txt", build_conflict_text(1)), - ("helper.txt", "same\n".to_string()), - ], - ); - - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - let _project_diff = cx - .update(|window, cx| { - ProjectDiff::new_with_default_branch(project.clone(), workspace, window, cx) - }) - .await - .unwrap(); - cx.run_until_parked(); - - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/project/conflict.txt"), cx) - }) - .await - .unwrap(); - buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "dirty\n")], None, cx)); - assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty())); - cx.run_until_parked(); - - cx.update(|window, cx| { - let fs = fs.clone(); - window - .spawn(cx, async move |cx| { - cx.background_executor().simulate_random_delay().await; - fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { - state.refs.insert("HEAD".into(), "head-1".into()); - state.refs.remove("MERGE_HEAD"); - }) - .unwrap(); - fs.set_status_for_repo( - path!("/project/.git").as_ref(), - &[ - ( - "conflict.txt", - FileStatus::Tracked(TrackedStatus { - index_status: git::status::StatusCode::Modified, - worktree_status: git::status::StatusCode::Modified, - }), - ), - ( - "helper.txt", - FileStatus::Tracked(TrackedStatus { - index_status: git::status::StatusCode::Modified, - worktree_status: git::status::StatusCode::Modified, - }), - ), - ], - ); - // FakeFs assigns deterministic OIDs by entry position; flipping order churns - // conflict diff identity without reaching into ProjectDiff internals. - fs.set_merge_base_content_for_repo( - path!("/project/.git").as_ref(), - &[ - ("helper.txt", "helper-base\n".to_string()), - ("conflict.txt", build_conflict_text(2)), - ], - ); - }) - .detach(); - }); - - cx.update(|window, cx| { - let buffer = buffer.clone(); - window - .spawn(cx, async move |cx| { - cx.background_executor().simulate_random_delay().await; - for edit_index in 0..10 { - if edit_index > 0 { - cx.background_executor().simulate_random_delay().await; - } - buffer.update(cx, |buffer, cx| { - let len = buffer.len(); - if edit_index % 2 == 0 { - buffer.edit( - [(0..0, format!("status-burst-head-{edit_index}\n"))], - None, - cx, - ); - } else { - buffer.edit( - [(len..len, format!("status-burst-tail-{edit_index}\n"))], - None, - cx, - ); - } - }); - } - }) - .detach(); - }); - - cx.run_until_parked(); - } - #[gpui::test] async fn test_new_hunk_in_modified_file(cx: &mut TestAppContext) { init_test(cx); @@ -2765,7 +1791,7 @@ mod tests { ); cx.run_until_parked(); - let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone()); + let editor = diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone()); assert_state_with_diff( &editor, @@ -2940,323 +1966,79 @@ mod tests { } #[gpui::test] - async fn test_branch_diff(cx: &mut TestAppContext) { + async fn test_excerpts_splitting_after_restoring_the_middle_excerpt(cx: &mut TestAppContext) { init_test(cx); + let git_contents = indoc! {r#" + #[rustfmt::skip] + fn main() { + let x = 0.0; // this line will be removed + // 1 + // 2 + // 3 + let y = 0.0; // this line will be removed + // 1 + // 2 + // 3 + let arr = [ + 0.0, // this line will be removed + 0.0, // this line will be removed + 0.0, // this line will be removed + 0.0, // this line will be removed + ]; + } + "#}; + let buffer_contents = indoc! {" + #[rustfmt::skip] + fn main() { + // 1 + // 2 + // 3 + // 1 + // 2 + // 3 + let arr = [ + ]; + } + "}; + let fs = FakeFs::new(cx.executor()); fs.insert_tree( - path!("/project"), + path!("/a"), json!({ ".git": {}, - "a.txt": "C", - "b.txt": "new", - "c.txt": "in-merge-base-and-work-tree", - "d.txt": "created-in-head", + "main.rs": buffer_contents, }), ) .await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - let diff = cx - .update(|window, cx| { - ProjectDiff::new_with_default_branch(project.clone(), workspace, window, cx) - }) - .await - .unwrap(); - cx.run_until_parked(); - - fs.set_head_for_repo( - Path::new(path!("/project/.git")), - &[("a.txt", "B".into()), ("d.txt", "created-in-head".into())], - "sha", - ); - // fs.set_index_for_repo(dot_git, index_state); - fs.set_merge_base_content_for_repo( - Path::new(path!("/project/.git")), - &[ - ("a.txt", "A".into()), - ("c.txt", "in-merge-base-and-work-tree".into()), - ], - ); - cx.run_until_parked(); - - let editor = diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone()); - - assert_state_with_diff( - &editor, - cx, - &" - - A - + ˇC - + new - + created-in-head" - .unindent(), - ); - - let statuses: HashMap, Option> = - editor.update(cx, |editor, cx| { - editor - .buffer() - .read(cx) - .all_buffers() - .iter() - .map(|buffer| { - ( - buffer.read(cx).file().unwrap().path().clone(), - editor.status_for_buffer_id(buffer.read(cx).remote_id(), cx), - ) - }) - .collect() - }); - - assert_eq!( - statuses, - HashMap::from_iter([ - ( - rel_path("a.txt").into_arc(), - Some(FileStatus::Tracked(TrackedStatus { - index_status: git::status::StatusCode::Modified, - worktree_status: git::status::StatusCode::Modified - })) - ), - (rel_path("b.txt").into_arc(), Some(FileStatus::Untracked)), - ( - rel_path("d.txt").into_arc(), - Some(FileStatus::Tracked(TrackedStatus { - index_status: git::status::StatusCode::Added, - worktree_status: git::status::StatusCode::Added - })) - ) - ]) - ); - } - - #[gpui::test] - async fn test_branch_diff_action_matches_existing_item_by_base_ref(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - ".git": {}, - "a.txt": "changed", - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - let target_branch_diff = cx - .update(|window, cx| { - let Some(repository) = project.read(cx).active_repository(cx) else { - return Task::ready(Err(anyhow!("No active repository"))); - }; - ProjectDiff::new_with_branch_base( - project.clone(), - workspace.clone(), - "topic".into(), - repository, - window, - cx, - ) - }) - .await - .unwrap(); - workspace.update_in(cx, |workspace, window, cx| { - workspace.add_item_to_active_pane( - Box::new(target_branch_diff.clone()), - None, - true, - window, - cx, - ); - }); - cx.run_until_parked(); - - cx.focus(&workspace); - cx.update(|window, cx| { - window.dispatch_action(BranchDiff.boxed_clone(), cx); - }); - cx.run_until_parked(); - - let (active_base_ref, mut base_refs) = workspace.update(cx, |workspace, cx| { - let active_item = workspace.active_item_as::(cx).unwrap(); - let active_base_ref = match active_item.read(cx).diff_base(cx) { - DiffBase::Merge { base_ref } => base_ref.to_string(), - DiffBase::Head => panic!("expected active item to be a branch diff"), - }; - let base_refs = workspace - .items_of_type::(cx) - .filter_map(|item| match item.read(cx).diff_base(cx) { - DiffBase::Merge { base_ref } => Some(base_ref.to_string()), - DiffBase::Head => None, - }) - .collect::>(); - (active_base_ref, base_refs) - }); - base_refs.sort(); - - assert_eq!(active_base_ref, "origin/main"); - assert_eq!(base_refs, vec!["origin/main", "topic"]); - } - - #[gpui::test] - async fn test_update_on_uncommit(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - ".git": {}, - "README.md": "# My cool project\n".to_owned() - }), - ) - .await; - fs.set_head_and_index_for_repo( - Path::new(path!("/project/.git")), - &[("README.md", "# My cool project\n".to_owned())], - ); - let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; - let worktree_id = project.read_with(cx, |project, cx| { - project.worktrees(cx).next().unwrap().read(cx).id() - }); - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - cx.run_until_parked(); - - let _editor = workspace - .update_in(cx, |workspace, window, cx| { - workspace.open_path((worktree_id, rel_path("README.md")), None, true, window, cx) - }) - .await - .unwrap() - .downcast::() - .unwrap(); - - cx.focus(&workspace); - cx.update(|window, cx| { - window.dispatch_action(project_diff::Diff.boxed_clone(), cx); - }); - cx.run_until_parked(); - let item = workspace.update(cx, |workspace, cx| { - workspace.active_item_as::(cx).unwrap() - }); - cx.focus(&item); - let editor = item.read_with(cx, |item, cx| item.editor.read(cx).rhs_editor().clone()); fs.set_head_and_index_for_repo( - Path::new(path!("/project/.git")), - &[( - "README.md", - "# My cool project\nDetails to come.\n".to_owned(), - )], + Path::new(path!("/a/.git")), + &[("main.rs", git_contents.to_owned())], ); + + let project = Project::test(fs, [Path::new(path!("/a"))], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + cx.run_until_parked(); + let diff = cx.new_window_entity(|window, cx| { + ProjectDiff::new(project.clone(), workspace, window, cx) + }); + cx.run_until_parked(); + let editor = diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone()); + let mut cx = EditorTestContext::for_editor_in(editor, cx).await; - cx.assert_excerpts_with_selections("[EXCERPT]\nˇ# My cool project\nDetails to come.\n"); - } + cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}")); - #[gpui::test] - async fn test_deploy_at_respects_active_repository_selection(cx: &mut TestAppContext) { - init_test(cx); + cx.dispatch_action(editor::actions::GoToHunk); + cx.dispatch_action(editor::actions::GoToHunk); + cx.dispatch_action(git::Restore); + cx.dispatch_action(editor::actions::MoveToBeginning); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project_a"), - json!({ - ".git": {}, - "a.txt": "CHANGED_A\n", - }), - ) - .await; - fs.insert_tree( - path!("/project_b"), - json!({ - ".git": {}, - "b.txt": "CHANGED_B\n", - }), - ) - .await; - - fs.set_head_and_index_for_repo( - Path::new(path!("/project_a/.git")), - &[("a.txt", "original_a\n".to_string())], - ); - fs.set_head_and_index_for_repo( - Path::new(path!("/project_b/.git")), - &[("b.txt", "original_b\n".to_string())], - ); - - let project = Project::test( - fs.clone(), - [ - Path::new(path!("/project_a")), - Path::new(path!("/project_b")), - ], - cx, - ) - .await; - - let (worktree_a_id, worktree_b_id) = project.read_with(cx, |project, cx| { - let mut worktrees: Vec<_> = project.worktrees(cx).collect(); - worktrees.sort_by_key(|w| w.read(cx).abs_path()); - (worktrees[0].read(cx).id(), worktrees[1].read(cx).id()) - }); - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - cx.run_until_parked(); - - // Select project A explicitly and open the diff. - workspace.update(cx, |workspace, cx| { - let git_store = workspace.project().read(cx).git_store().clone(); - git_store.update(cx, |git_store, cx| { - git_store.set_active_repo_for_worktree(worktree_a_id, cx); - }); - }); - cx.focus(&workspace); - cx.update(|window, cx| { - window.dispatch_action(project_diff::Diff.boxed_clone(), cx); - }); - cx.run_until_parked(); - - let diff_item = workspace.update(cx, |workspace, cx| { - workspace.active_item_as::(cx).unwrap() - }); - let paths_a = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx)); - assert_eq!(paths_a.len(), 1); - assert_eq!(*paths_a[0], *"a.txt"); - - // Switch the explicit active repository to project B and re-run the diff action. - workspace.update(cx, |workspace, cx| { - let git_store = workspace.project().read(cx).git_store().clone(); - git_store.update(cx, |git_store, cx| { - git_store.set_active_repo_for_worktree(worktree_b_id, cx); - }); - }); - cx.focus(&workspace); - cx.update(|window, cx| { - window.dispatch_action(project_diff::Diff.boxed_clone(), cx); - }); - cx.run_until_parked(); - - let same_diff_item = workspace.update(cx, |workspace, cx| { - workspace.active_item_as::(cx).unwrap() - }); - assert_eq!(diff_item.entity_id(), same_diff_item.entity_id()); - - let paths_b = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx)); - assert_eq!(paths_b.len(), 1); - assert_eq!(*paths_b[0], *"b.txt"); + cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}")); } } diff --git a/crates/git_ui/src/staged_diff.rs b/crates/git_ui/src/staged_diff.rs new file mode 100644 index 00000000000..9b607c72cbc --- /dev/null +++ b/crates/git_ui/src/staged_diff.rs @@ -0,0 +1,1090 @@ +use crate::{ + diff_multibuffer::DiffMultibuffer, + git_panel::{GitPanel, GitPanelAddon, GitStatusEntry}, +}; +use anyhow::{Context as _, Result}; +use buffer_diff::DiffHunkStatus; +use editor::{ + DiffHunkDelegate, Editor, EditorEvent, ResolvedDiffHunks, SplittableEditor, + actions::{GoToHunk, GoToPreviousHunk}, +}; +use git::{Commit, UnstageAll, UnstageAndNext}; +use gpui::{ + Action, AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, + SharedString, Subscription, Task, WeakEntity, +}; +use language::Capability; +use project::{ + Project, ProjectPath, + git_store::diff_buffer_list::{DiffBase, DiffBufferList}, + project_settings::ProjectSettings, +}; +use settings::Settings; +use std::{ + any::{Any, TypeId}, + ops::Range, + sync::Arc, +}; +use ui::{DiffStat, Divider, Icon, Tooltip, Window, prelude::*}; +use util::ResultExt as _; +use workspace::{ + ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, + Workspace, + item::{Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams}, + searchable::SearchableItemHandle, +}; + +pub(crate) struct StagedDiffDelegate; + +impl DiffHunkDelegate for StagedDiffDelegate { + fn toggle( + &self, + hunks: Vec, + editor: &mut Editor, + window: &mut Window, + cx: &mut Context, + ) { + self.stage_or_unstage(false, hunks, editor, window, cx); + } + + fn stage_or_unstage( + &self, + stage: bool, + hunks: Vec, + editor: &mut Editor, + _window: &mut Window, + cx: &mut Context, + ) { + if stage { + return; + } + let Some(project) = editor.project().cloned() else { + return; + }; + for hunks in hunks { + let index_ranges = hunks + .hunks + .into_iter() + .map(|hunk| hunk.buffer_range) + .collect::>(); + if index_ranges.is_empty() { + continue; + } + project + .update(cx, |project, cx| { + project.unstage_staged_hunks(hunks.diff, index_ranges, cx) + }) + .log_err(); + } + } + + fn render_hunk_controls( + &self, + row: u32, + status: &DiffHunkStatus, + hunk_range: Range, + _is_created_file: bool, + line_height: Pixels, + editor: &Entity, + _window: &mut Window, + cx: &mut App, + ) -> AnyElement { + if !ProjectSettings::get_global(cx) + .git + .show_stage_restore_buttons + { + return gpui::Empty.into_any_element(); + } + let hunk_range = hunk_range.start..hunk_range.start; + h_flex() + .h(line_height) + .mr_1() + .gap_1() + .px_0p5() + .pb_1() + .border_x_1() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .rounded_b_lg() + .bg(cx.theme().colors().editor_background) + .block_mouse_except_scroll() + .shadow_md() + .child( + Button::new(("unstage", row as u64), "Unstage") + .alpha(if status.is_pending() { 0.66 } else { 1.0 }) + .tooltip(Tooltip::text("Unstage Hunk")) + .on_click({ + let editor = editor.clone(); + move |_event, window, cx| { + editor.update(cx, |editor, cx| { + editor.stage_or_unstage_diff_hunks( + false, + vec![hunk_range.clone()], + window, + cx, + ); + }); + } + }), + ) + .into_any_element() + } +} + +/// The workspace item for the staged diff. It wraps a single read-only +/// [`DiffMultibuffer`] over [`DiffBase::Staged`] and delegates the [`Item`] +/// surface to it. +pub struct StagedDiff { + diff: Entity, + project: Entity, + workspace: WeakEntity, + _diff_event_subscription: Subscription, +} + +impl StagedDiff { + pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) { + let _ = workspace; + workspace::register_serializable_item::(cx); + } + + pub fn deploy_at( + workspace: &mut Workspace, + entry: Option, + window: &mut Window, + cx: &mut Context, + ) { + telemetry::event!( + "Git Staged Diff Opened", + source = if entry.is_some() { + "Git Panel" + } else { + "Action" + } + ); + let intended_repo = workspace.project().read(cx).active_repository(cx); + let existing = workspace.items_of_type::(cx).next(); + let staged_diff = if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + existing + } else { + let workspace_handle = cx.entity(); + let staged_diff = + cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx)); + workspace.add_item_to_active_pane( + Box::new(staged_diff.clone()), + None, + true, + window, + cx, + ); + staged_diff + }; + + if let Some(intended) = &intended_repo { + let needs_switch = staged_diff + .read(cx) + .diff + .read(cx) + .repo(cx) + .map_or(true, |current| current.entity_id() != intended.entity_id()); + if needs_switch { + staged_diff.update(cx, |staged_diff, cx| { + staged_diff.diff.update(cx, |diff, cx| { + diff.set_repo(Some(intended.clone()), cx); + }); + }); + } + } + + if let Some(entry) = entry { + staged_diff.update(cx, |staged_diff, cx| { + staged_diff + .diff + .update(cx, |diff, cx| diff.move_to_entry(entry, window, cx)); + }); + } + } + + pub(crate) fn new( + project: Entity, + workspace: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let branch_diff = + cx.new(|cx| DiffBufferList::new(DiffBase::Staged, project.clone(), window, cx)); + let workspace_handle = workspace.downgrade(); + let diff = cx.new(|cx| { + DiffMultibuffer::new( + branch_diff, + Capability::ReadOnly, + "No staged changes", + move |editor, cx| { + editor.set_diff_hunk_delegate(Some(Arc::new(StagedDiffDelegate)), cx); + editor.rhs_editor().update(cx, |rhs_editor, _cx| { + rhs_editor.set_read_only(true); + rhs_editor.register_addon(GitPanelAddon { + workspace: workspace_handle, + }); + }); + }, + project.clone(), + workspace.clone(), + window, + cx, + ) + }); + Self::from_diff(diff, project, workspace, cx) + } + + pub(crate) fn from_diff( + diff: Entity, + project: Entity, + workspace: Entity, + cx: &mut Context, + ) -> Self { + let diff_event_subscription = cx.subscribe(&diff, |_, _, event: &EditorEvent, cx| { + cx.emit(event.clone()) + }); + + Self { + diff, + project, + workspace: workspace.downgrade(), + _diff_event_subscription: diff_event_subscription, + } + } + + fn button_states(&self, cx: &App) -> ButtonStates { + let diff = self.diff.read(cx); + let editor = diff.editor().read(cx).rhs_editor().clone(); + let editor = editor.read(cx); + let snapshot = diff.multibuffer().read(cx).snapshot(cx); + let prev_next = snapshot.diff_hunks().nth(1).is_some(); + let (selection, ranges) = diff.selected_ranges(cx); + let unstage = editor + .diff_hunks_in_ranges(&ranges, &snapshot) + .next() + .is_some(); + let mut unstage_all = false; + self.workspace + .read_with(cx, |workspace, cx| { + if let Some(git_panel) = workspace.panel::(cx) { + unstage_all = git_panel.read(cx).can_unstage_all(); + } + }) + .ok(); + + ButtonStates { + unstage, + prev_next, + selection, + unstage_all, + } + } + + fn unstage_selected_staged_hunks( + &mut self, + move_to_next: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.diff.update(cx, |diff, cx| { + diff.stage_or_unstage_selected_hunks(false, move_to_next, window, cx) + }); + } +} + +struct ButtonStates { + unstage: bool, + prev_next: bool, + selection: bool, + unstage_all: bool, +} + +impl EventEmitter for StagedDiff {} + +impl Focusable for StagedDiff { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.diff.read(cx).focus_handle(cx) + } +} + +impl Item for StagedDiff { + type Event = EditorEvent; + + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(Icon::new(IconName::GitBranch).color(Color::Muted)) + } + + fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) { + Editor::to_item_events(event, f) + } + + fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { + self.diff + .update(cx, |diff, cx| diff.deactivated(window, cx)); + } + + fn navigate( + &mut self, + data: Arc, + window: &mut Window, + cx: &mut Context, + ) -> bool { + self.diff + .update(cx, |diff, cx| diff.navigate(data, window, cx)) + } + + fn tab_tooltip_text(&self, _: &App) -> Option { + Some("Staged Changes".into()) + } + + fn tab_content(&self, params: TabContentParams, _window: &Window, _cx: &App) -> AnyElement { + Label::new(self.tab_content_text(0, _cx)) + .color(if params.selected { + Color::Default + } else { + Color::Muted + }) + .into_any_element() + } + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Staged Changes".into() + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("Git Staged Diff Opened") + } + + fn as_searchable(&self, _: &Entity, cx: &App) -> Option> { + Some(Box::new(self.diff.read(cx).editor().clone())) + } + + fn for_each_project_item( + &self, + cx: &App, + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), + ) { + self.diff.read(cx).for_each_project_item(cx, f); + } + + fn set_nav_history( + &mut self, + nav_history: ItemNavHistory, + _: &mut Window, + cx: &mut Context, + ) { + self.diff + .update(cx, |diff, cx| diff.set_nav_history(nav_history, cx)); + } + + fn can_split(&self) -> bool { + true + } + + fn clone_on_split( + &self, + _workspace_id: Option, + window: &mut Window, + cx: &mut Context, + ) -> Task>> + where + Self: Sized, + { + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(None); + }; + let project = self.project.clone(); + Task::ready(Some(cx.new(|cx| Self::new(project, workspace, window, cx)))) + } + + fn is_dirty(&self, cx: &App) -> bool { + self.diff.read(cx).is_dirty(cx) + } + + fn has_conflict(&self, cx: &App) -> bool { + self.diff.read(cx).has_conflict(cx) + } + + fn can_save(&self, _: &App) -> bool { + false + } + + fn save( + &mut self, + _: SaveOptions, + _: Entity, + _: &mut Window, + _: &mut Context, + ) -> Task> { + Task::ready(Ok(())) + } + + fn save_as( + &mut self, + _: Entity, + _: ProjectPath, + _: &mut Window, + _: &mut Context, + ) -> Task> { + unreachable!() + } + + fn reload( + &mut self, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.diff + .update(cx, |diff, cx| diff.reload(project, window, cx)) + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a Entity, + cx: &'a App, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.clone().into()) + } else if type_id == TypeId::of::() { + Some(self.diff.clone().into()) + } else if type_id == TypeId::of::() { + Some( + self.diff + .read(cx) + .editor() + .read(cx) + .rhs_editor() + .clone() + .into(), + ) + } else if type_id == TypeId::of::() { + Some(self.diff.read(cx).editor().clone().into()) + } else if type_id == TypeId::of::() { + Some(self.diff.read(cx).branch_diff().clone().into()) + } else { + None + } + } + + fn added_to_workspace( + &mut self, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + self.diff.update(cx, |diff, cx| { + diff.added_to_workspace(workspace, window, cx) + }); + } +} + +impl SerializableItem for StagedDiff { + fn serialized_item_kind() -> &'static str { + "StagedDiff" + } + + fn cleanup( + _: workspace::WorkspaceId, + _: Vec, + _: &mut Window, + _: &mut App, + ) -> Task> { + Task::ready(Ok(())) + } + + fn deserialize( + project: Entity, + workspace: WeakEntity, + _: workspace::WorkspaceId, + _: workspace::ItemId, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + window.spawn(cx, async move |cx| { + let workspace = workspace.upgrade().context("workspace gone")?; + cx.update(|window, cx| Ok(cx.new(|cx| Self::new(project, workspace, window, cx))))? + }) + } + + fn serialize( + &mut self, + _: &mut Workspace, + _: workspace::ItemId, + _: bool, + _: &mut Window, + _: &mut Context, + ) -> Option>> { + Some(Task::ready(Ok(()))) + } + + fn should_serialize(&self, _: &Self::Event) -> bool { + false + } +} + +impl Render for StagedDiff { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + self.diff.clone() + } +} + +pub struct StagedDiffToolbar { + staged_diff: Option>, + workspace: WeakEntity, +} + +impl StagedDiffToolbar { + pub fn new(workspace: &Workspace, _: &mut Context) -> Self { + Self { + staged_diff: None, + workspace: workspace.weak_handle(), + } + } + + fn staged_diff(&self, _: &App) -> Option> { + self.staged_diff.as_ref()?.upgrade() + } + + fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context) { + if let Some(staged_diff) = self.staged_diff(cx) { + staged_diff.focus_handle(cx).focus(window, cx); + } + let action = action.boxed_clone(); + cx.defer(move |cx| { + cx.dispatch_action(action.as_ref()); + }) + } + + fn unstage_selected_staged_hunks( + &mut self, + move_to_next: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(staged_diff) = self.staged_diff(cx) else { + return; + }; + staged_diff.update(cx, |staged_diff, cx| { + staged_diff.unstage_selected_staged_hunks(move_to_next, window, cx); + }); + } + + fn unstage_all(&mut self, window: &mut Window, cx: &mut Context) { + self.workspace + .update(cx, |workspace, cx| { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + panel.update(cx, |panel, cx| { + panel.unstage_all(&Default::default(), window, cx); + }); + }) + .ok(); + } +} + +impl EventEmitter for StagedDiffToolbar {} + +impl ToolbarItemView for StagedDiffToolbar { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _: &mut Window, + cx: &mut Context, + ) -> ToolbarItemLocation { + self.staged_diff = active_pane_item + .and_then(|item| item.act_as::(cx)) + .map(|entity| entity.downgrade()); + if self.staged_diff.is_some() { + ToolbarItemLocation::PrimaryRight + } else { + ToolbarItemLocation::Hidden + } + } + + fn pane_focus_update( + &mut self, + _pane_focused: bool, + _window: &mut Window, + _cx: &mut Context, + ) { + } +} + +impl Render for StagedDiffToolbar { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(staged_diff) = self.staged_diff(cx) else { + return div(); + }; + let focus_handle = staged_diff.focus_handle(cx); + let button_states = staged_diff.read(cx).button_states(cx); + + let diff = staged_diff.read(cx).diff.read(cx); + let (additions, deletions) = diff.calculate_changed_lines(cx); + let is_multibuffer_empty = diff.multibuffer().read(cx).is_empty(); + + h_flex() + .my_neg_1() + .py_1() + .gap_1p5() + .flex_wrap() + .justify_between() + .when(!is_multibuffer_empty, |this| { + this.child(DiffStat::new( + "staged-diff-stat", + additions as usize, + deletions as usize, + )) + .child(Divider::vertical().ml_1()) + }) + // n.b. the only reason these arrows are here is because we don't + // support "undo" for staging so we need a way to go back. + .child( + h_group_sm() + .child( + IconButton::new("up", IconName::ArrowUp) + .icon_size(IconSize::Small) + .disabled(!button_states.prev_next) + .tooltip(Tooltip::for_action_title_in( + "Go to Previous Hunk", + &GoToPreviousHunk, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&GoToPreviousHunk, window, cx) + })), + ) + .child( + IconButton::new("down", IconName::ArrowDown) + .icon_size(IconSize::Small) + .disabled(!button_states.prev_next) + .tooltip(Tooltip::for_action_title_in( + "Go to Next Hunk", + &GoToHunk, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&GoToHunk, window, cx) + })), + ), + ) + .child(Divider::vertical()) + .child( + h_group_sm() + .when(button_states.selection, |this| { + this.child( + Button::new("unstage", "Unstage") + .disabled(!button_states.unstage) + .tooltip(Tooltip::text("Unstage Selected Hunks")) + .on_click(cx.listener(|this, _, window, cx| { + this.unstage_selected_staged_hunks(false, window, cx) + })), + ) + }) + .when(!button_states.selection, |this| { + this.child( + Button::new("unstage", "Unstage") + .disabled(!button_states.unstage) + .tooltip(Tooltip::for_action_title_in( + "Unstage and Go to Next Hunk", + &UnstageAndNext, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.unstage_selected_staged_hunks(true, window, cx) + })), + ) + }), + ) + .child(Divider::vertical()) + .child( + Button::new("unstage-all", "Unstage All") + .width(rems_from_px(80.)) + .disabled(!button_states.unstage_all) + .tooltip(Tooltip::for_action_title_in( + "Unstage All Changes", + &UnstageAll, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| this.unstage_all(window, cx))), + ) + .child(Divider::vertical()) + .child( + Button::new("commit", "Commit") + .tooltip(Tooltip::for_action_title_in( + "Commit", + &Commit, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&Commit, window, cx); + })), + ) + } +} + +#[cfg(test)] +mod tests { + use crate::project_diff::{self, ProjectDiff}; + use git::repository::RepoPath; + use gpui::{Action as _, TestAppContext}; + use language::Point; + use project::{FakeFs, Fs as _}; + use serde_json::json; + use settings::{DiffViewStyle, SettingsStore}; + use std::path::Path; + use unindent::Unindent as _; + use util::{path, rel_path::rel_path}; + use workspace::MultiWorkspace; + + use super::*; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.diff_view_style = Some(DiffViewStyle::Unified); + }); + }); + theme_settings::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + crate::init(cx); + }); + } + + #[gpui::test] + async fn test_staged_changes_deploy_as_a_separate_staged_diff_item(cx: &mut TestAppContext) { + init_test(cx); + + let committed_contents = r#" + fn main() { + println!("hello world"); + } + "# + .unindent(); + let staged_contents = r#" + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + let file_contents = r#" + // print goodbye + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "src": { + "main.rs": file_contents.clone(), + } + }), + ) + .await; + + fs.set_head_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", committed_contents)], + "deadbeef", + ); + fs.set_index_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", staged_contents.clone())], + ); + + let project = Project::test(fs, [path!("/project").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()); + cx.run_until_parked(); + + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let uncommitted_item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + + workspace.update_in(cx, |workspace, window, cx| { + StagedDiff::deploy_at(workspace, None, window, cx); + }); + cx.run_until_parked(); + + workspace.update(cx, |workspace, cx| { + let staged_diff = workspace.active_item_as::(cx).unwrap(); + assert_ne!(staged_diff.entity_id(), uncommitted_item.entity_id()); + let staged_item = workspace + .active_item(cx) + .unwrap() + .act_as::(cx) + .unwrap(); + assert_ne!(staged_item.entity_id(), uncommitted_item.entity_id()); + assert_eq!( + staged_item.read_with(cx, |diff, cx| diff.diff_base(cx).clone()), + DiffBase::Staged + ); + assert!(staged_item.read_with(cx, |diff, cx| diff.multibuffer().read(cx).read_only())); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + + let active_item = workspace.active_item(cx).unwrap(); + assert!(active_item.act_as::(cx).is_some()); + assert!(active_item.act_as::(cx).is_some()); + assert_eq!( + active_item + .to_serializable_item_handle(cx) + .unwrap() + .serialized_item_kind(), + "StagedDiff" + ); + assert_eq!(active_item.tab_content_text(0, cx), "Staged Changes"); + assert!(!active_item.can_save(cx)); + }); + } + + #[gpui::test] + async fn test_toggle_staged_unstages_from_staged_view(cx: &mut TestAppContext) { + init_test(cx); + + let committed_contents = r#" + fn main() { + println!("hello world"); + } + "# + .unindent(); + let staged_contents = r#" + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + let file_contents = r#" + // print goodbye + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "src": { + "main.rs": file_contents, + } + }), + ) + .await; + fs.set_head_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", committed_contents.clone())], + "deadbeef", + ); + fs.set_index_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", staged_contents)], + ); + let repo = fs + .open_repo(path!("/project/.git").as_ref(), Some("git".as_ref())) + .unwrap(); + + let project = Project::test(fs.clone(), [path!("/project").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()); + cx.run_until_parked(); + + workspace.update_in(cx, |workspace, window, cx| { + StagedDiff::deploy_at(workspace, None, window, cx); + }); + cx.run_until_parked(); + + let editor = workspace.update(cx, |workspace, cx| { + let staged_diff = workspace.active_item_as::(cx).unwrap(); + let staged_diff = staged_diff.read(cx); + staged_diff + .diff + .read(cx) + .editor() + .read(cx) + .rhs_editor() + .clone() + }); + editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + assert_eq!( + editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot) + .count(), + 1 + ); + }); + + // Hold back FS events so the first assertions observe the optimistic + // state rather than a reloaded diff. + fs.pause_events(); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges([Point::new(1, 0)..Point::new(1, 0)]); + }); + }); + cx.focus(&editor); + cx.update(|window, cx| { + window.dispatch_action(git::ToggleStaged.boxed_clone(), cx); + }); + cx.run_until_parked(); + + // The hunk is optimistically suppressed from the staged view, and the + // index write has landed. + editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + assert_eq!( + editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot) + .count(), + 0 + ); + }); + assert_eq!( + repo.load_index_text(RepoPath::from_rel_path(rel_path("src/main.rs"))) + .await + .unwrap(), + committed_contents + ); + + fs.unpause_events_and_flush(); + cx.run_until_parked(); + + // Once the write is reconciled, the staged view remains empty. + editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + assert_eq!( + editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot) + .count(), + 0 + ); + }); + } + + #[gpui::test] + async fn test_staged_diff_restores_as_staged_diff(cx: &mut TestAppContext) { + init_test(cx); + + let committed_contents = r#" + fn main() { + println!("hello world"); + } + "# + .unindent(); + let staged_contents = r#" + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + let file_contents = r#" + // print goodbye + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "src": { + "main.rs": file_contents, + } + }), + ) + .await; + + fs.set_head_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", committed_contents)], + "deadbeef", + ); + fs.set_index_for_repo( + Path::new(path!("/project/.git")), + &[("src/main.rs", staged_contents)], + ); + + let project = Project::test(fs, [path!("/project").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()); + cx.run_until_parked(); + + let project = workspace.update(cx, |workspace, _| workspace.project().clone()); + let workspace_id = workspace::WorkspaceId::from_i64(1); + let item_id = 42; + + let restore_task = workspace.update_in(cx, |_workspace, window, cx| { + ::deserialize( + project.clone(), + cx.entity().downgrade(), + workspace_id, + item_id, + window, + cx, + ) + }); + let restored_staged_diff = restore_task.await.unwrap(); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane( + Box::new(restored_staged_diff.clone()), + None, + true, + window, + cx, + ); + }); + cx.run_until_parked(); + + workspace.update(cx, |workspace, cx| { + let active_item = workspace.active_item(cx).unwrap(); + assert!(active_item.act_as::(cx).is_some()); + assert!(active_item.act_as::(cx).is_some()); + assert_eq!( + active_item + .to_serializable_item_handle(cx) + .unwrap() + .serialized_item_kind(), + "StagedDiff" + ); + assert_eq!(active_item.tab_content_text(0, cx), "Staged Changes"); + assert!(!active_item.can_save(cx)); + assert_eq!(workspace.items_of_type::(cx).count(), 0); + assert_eq!(workspace.items_of_type::(cx).count(), 1); + let diff = active_item.act_as::(cx).unwrap(); + assert_eq!( + diff.read_with(cx, |diff, cx| diff.diff_base(cx).clone()), + DiffBase::Staged + ); + }); + } +} diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs index 5312ae6d088..991ebf21252 100644 --- a/crates/git_ui/src/text_diff_view.rs +++ b/crates/git_ui/src/text_diff_view.rs @@ -3,8 +3,8 @@ use anyhow::Result; use buffer_diff::BufferDiff; use editor::{ - Editor, EditorEvent, EditorSettings, MultiBuffer, SplittableEditor, ToPoint, - actions::DiffClipboardWithSelectionData, + Editor, EditorEvent, EditorSettings, MultiBuffer, RestoreOnlyUnstagedDiffHunkDelegate, + SplittableEditor, ToPoint, actions::DiffClipboardWithSelectionData, }; use futures::{FutureExt, select_biased}; use gpui::{ @@ -184,11 +184,8 @@ impl TextDiffView { window, cx, ); - splittable.disable_diff_hunk_controls(cx); - splittable.set_render_diff_hunks_as_unstaged(cx); - splittable.rhs_editor().update(cx, |editor, _cx| { - editor.start_temporary_diff_override(); - }); + splittable + .set_diff_hunk_delegate(Some(Arc::new(RestoreOnlyUnstagedDiffHunkDelegate)), cx); splittable }); diff --git a/crates/git_ui/src/unstaged_diff.rs b/crates/git_ui/src/unstaged_diff.rs new file mode 100644 index 00000000000..44f700c722a --- /dev/null +++ b/crates/git_ui/src/unstaged_diff.rs @@ -0,0 +1,722 @@ +use crate::{ + diff_multibuffer::DiffMultibuffer, + git_panel::{GitPanel, GitPanelAddon, GitStatusEntry}, +}; +use anyhow::{Context as _, Result}; +use buffer_diff::DiffHunkStatus; +use editor::{ + DiffHunkDelegate, Editor, EditorEvent, ResolvedDiffHunks, SplittableEditor, + actions::{GoToHunk, GoToPreviousHunk}, +}; +use git::{StageAll, StageAndNext}; +use gpui::{ + Action, AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, + SharedString, Subscription, Task, WeakEntity, +}; +use language::Capability; +use project::{ + Project, ProjectPath, + git_store::diff_buffer_list::{DiffBase, DiffBufferList}, + project_settings::ProjectSettings, +}; +use settings::Settings; +use std::{ + any::{Any, TypeId}, + ops::Range, + sync::Arc, +}; +use ui::{DiffStat, Divider, Icon, Tooltip, Window, prelude::*}; +use util::ResultExt as _; +use workspace::{ + ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, + Workspace, + item::{Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams}, + searchable::SearchableItemHandle, +}; + +pub(crate) struct UnstagedDiffDelegate; + +impl DiffHunkDelegate for UnstagedDiffDelegate { + fn toggle( + &self, + hunks: Vec, + editor: &mut Editor, + window: &mut Window, + cx: &mut Context, + ) { + self.stage_or_unstage(true, hunks, editor, window, cx); + } + + fn stage_or_unstage( + &self, + stage: bool, + hunks: Vec, + editor: &mut Editor, + _window: &mut Window, + cx: &mut Context, + ) { + if !stage { + return; + } + let Some(project) = editor.project().cloned() else { + return; + }; + for hunks in hunks { + let Some(buffer) = hunks.buffer else { + continue; + }; + let worktree_ranges = hunks + .hunks + .into_iter() + .map(|hunk| hunk.buffer_range) + .collect::>(); + if worktree_ranges.is_empty() { + continue; + } + project + .update(cx, |project, cx| { + project.stage_hunks(buffer, hunks.diff, worktree_ranges, cx) + }) + .log_err(); + } + } + + fn render_hunk_controls( + &self, + row: u32, + status: &DiffHunkStatus, + hunk_range: Range, + _is_created_file: bool, + line_height: Pixels, + editor: &Entity, + _window: &mut Window, + cx: &mut App, + ) -> AnyElement { + if !ProjectSettings::get_global(cx) + .git + .show_stage_restore_buttons + { + return gpui::Empty.into_any_element(); + } + let hunk_range = hunk_range.start..hunk_range.start; + h_flex() + .h(line_height) + .mr_1() + .gap_1() + .px_0p5() + .pb_1() + .border_x_1() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .rounded_b_lg() + .bg(cx.theme().colors().editor_background) + .block_mouse_except_scroll() + .shadow_md() + .child( + Button::new(("stage", row as u64), "Stage") + .alpha(if status.is_pending() { 0.66 } else { 1.0 }) + .tooltip(Tooltip::text("Stage Hunk")) + .on_click({ + let editor = editor.clone(); + move |_event, window, cx| { + editor.update(cx, |editor, cx| { + editor.stage_or_unstage_diff_hunks( + true, + vec![hunk_range.clone()], + window, + cx, + ); + }); + } + }), + ) + .into_any_element() + } + + fn render_hunk_as_staged(&self, _status: &DiffHunkStatus, _cx: &App) -> bool { + false + } +} + +pub struct UnstagedDiff { + diff: Entity, + project: Entity, + workspace: WeakEntity, + _diff_event_subscription: Subscription, +} + +impl UnstagedDiff { + pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) { + let _ = workspace; + workspace::register_serializable_item::(cx); + } + + pub fn deploy_at( + workspace: &mut Workspace, + entry: Option, + window: &mut Window, + cx: &mut Context, + ) { + telemetry::event!( + "Git Unstaged Diff Opened", + source = if entry.is_some() { + "Git Panel" + } else { + "Action" + } + ); + let intended_repo = workspace.project().read(cx).active_repository(cx); + let existing = workspace.items_of_type::(cx).next(); + let unstaged_diff = if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + existing + } else { + let workspace_handle = cx.entity(); + let unstaged_diff = + cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx)); + workspace.add_item_to_active_pane( + Box::new(unstaged_diff.clone()), + None, + true, + window, + cx, + ); + unstaged_diff + }; + + if let Some(intended) = &intended_repo { + let needs_switch = unstaged_diff + .read(cx) + .diff + .read(cx) + .repo(cx) + .map_or(true, |current| current.entity_id() != intended.entity_id()); + if needs_switch { + unstaged_diff.update(cx, |unstaged_diff, cx| { + unstaged_diff.diff.update(cx, |diff, cx| { + diff.set_repo(Some(intended.clone()), cx); + }); + }); + } + } + + if let Some(entry) = entry { + unstaged_diff.update(cx, |unstaged_diff, cx| { + unstaged_diff + .diff + .update(cx, |diff, cx| diff.move_to_entry(entry, window, cx)); + }); + } + } + + pub(crate) fn new( + project: Entity, + workspace: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let branch_diff = + cx.new(|cx| DiffBufferList::new(DiffBase::Index, project.clone(), window, cx)); + let workspace_handle = workspace.downgrade(); + let diff = cx.new(|cx| { + DiffMultibuffer::new( + branch_diff, + Capability::ReadWrite, + "No unstaged changes", + move |editor, cx| { + editor.set_diff_hunk_delegate(Some(Arc::new(UnstagedDiffDelegate)), cx); + editor.rhs_editor().update(cx, |rhs_editor, _cx| { + rhs_editor.set_read_only(false); + rhs_editor.register_addon(GitPanelAddon { + workspace: workspace_handle, + }); + }); + }, + project.clone(), + workspace.clone(), + window, + cx, + ) + }); + Self::from_diff(diff, project, workspace, cx) + } + + pub(crate) fn from_diff( + diff: Entity, + project: Entity, + workspace: Entity, + cx: &mut Context, + ) -> Self { + let diff_event_subscription = cx.subscribe(&diff, |_, _, event: &EditorEvent, cx| { + cx.emit(event.clone()) + }); + + Self { + diff, + project, + workspace: workspace.downgrade(), + _diff_event_subscription: diff_event_subscription, + } + } + + fn button_states(&self, cx: &App) -> ButtonStates { + let diff = self.diff.read(cx); + let editor = diff.editor().read(cx).rhs_editor().clone(); + let editor = editor.read(cx); + let snapshot = diff.multibuffer().read(cx).snapshot(cx); + let prev_next = snapshot.diff_hunks().nth(1).is_some(); + let (selection, ranges) = diff.selected_ranges(cx); + let stage = editor + .diff_hunks_in_ranges(&ranges, &snapshot) + .next() + .is_some(); + let mut stage_all = false; + self.workspace + .read_with(cx, |workspace, cx| { + if let Some(git_panel) = workspace.panel::(cx) { + stage_all = git_panel.read(cx).can_stage_all(); + } + }) + .ok(); + + ButtonStates { + stage, + prev_next, + selection, + stage_all, + } + } + + fn stage_selected_unstaged_hunks( + &mut self, + move_to_next: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.diff.update(cx, |diff, cx| { + diff.stage_or_unstage_selected_hunks(true, move_to_next, window, cx) + }); + } +} + +struct ButtonStates { + stage: bool, + prev_next: bool, + selection: bool, + stage_all: bool, +} + +impl EventEmitter for UnstagedDiff {} + +impl Focusable for UnstagedDiff { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.diff.read(cx).focus_handle(cx) + } +} + +impl Item for UnstagedDiff { + type Event = EditorEvent; + + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(Icon::new(IconName::GitBranch).color(Color::Muted)) + } + + fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) { + Editor::to_item_events(event, f) + } + + fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { + self.diff + .update(cx, |diff, cx| diff.deactivated(window, cx)); + } + + fn navigate( + &mut self, + data: Arc, + window: &mut Window, + cx: &mut Context, + ) -> bool { + self.diff + .update(cx, |diff, cx| diff.navigate(data, window, cx)) + } + + fn tab_tooltip_text(&self, _: &App) -> Option { + Some("Unstaged Changes".into()) + } + + fn tab_content(&self, params: TabContentParams, _window: &Window, _cx: &App) -> AnyElement { + Label::new(self.tab_content_text(0, _cx)) + .color(if params.selected { + Color::Default + } else { + Color::Muted + }) + .into_any_element() + } + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Unstaged Changes".into() + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("Git Unstaged Diff Opened") + } + + fn as_searchable(&self, _: &Entity, cx: &App) -> Option> { + Some(Box::new(self.diff.read(cx).editor().clone())) + } + + fn for_each_project_item( + &self, + cx: &App, + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), + ) { + self.diff.read(cx).for_each_project_item(cx, f); + } + + fn set_nav_history( + &mut self, + nav_history: ItemNavHistory, + _: &mut Window, + cx: &mut Context, + ) { + self.diff + .update(cx, |diff, cx| diff.set_nav_history(nav_history, cx)); + } + + fn can_split(&self) -> bool { + true + } + + fn clone_on_split( + &self, + _workspace_id: Option, + window: &mut Window, + cx: &mut Context, + ) -> Task>> + where + Self: Sized, + { + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(None); + }; + let project = self.project.clone(); + Task::ready(Some(cx.new(|cx| Self::new(project, workspace, window, cx)))) + } + + fn is_dirty(&self, cx: &App) -> bool { + self.diff.read(cx).is_dirty(cx) + } + + fn has_conflict(&self, cx: &App) -> bool { + self.diff.read(cx).has_conflict(cx) + } + + fn can_save(&self, _cx: &App) -> bool { + true + } + + fn save( + &mut self, + options: SaveOptions, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.diff + .update(cx, |diff, cx| diff.save(options, project, window, cx)) + } + + fn save_as( + &mut self, + _: Entity, + _: ProjectPath, + _: &mut Window, + _: &mut Context, + ) -> Task> { + unreachable!() + } + + fn reload( + &mut self, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.diff + .update(cx, |diff, cx| diff.reload(project, window, cx)) + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a Entity, + cx: &'a App, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.clone().into()) + } else if type_id == TypeId::of::() { + Some(self.diff.clone().into()) + } else if type_id == TypeId::of::() { + Some( + self.diff + .read(cx) + .editor() + .read(cx) + .rhs_editor() + .clone() + .into(), + ) + } else if type_id == TypeId::of::() { + Some(self.diff.read(cx).editor().clone().into()) + } else if type_id == TypeId::of::() { + Some(self.diff.read(cx).branch_diff().clone().into()) + } else { + None + } + } + + fn added_to_workspace( + &mut self, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + self.diff.update(cx, |diff, cx| { + diff.added_to_workspace(workspace, window, cx) + }); + } +} + +impl SerializableItem for UnstagedDiff { + fn serialized_item_kind() -> &'static str { + "UnstagedDiff" + } + + fn cleanup( + _: workspace::WorkspaceId, + _: Vec, + _: &mut Window, + _: &mut App, + ) -> Task> { + Task::ready(Ok(())) + } + + fn deserialize( + project: Entity, + workspace: WeakEntity, + _: workspace::WorkspaceId, + _: workspace::ItemId, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + window.spawn(cx, async move |cx| { + let workspace = workspace.upgrade().context("workspace gone")?; + cx.update(|window, cx| Ok(cx.new(|cx| Self::new(project, workspace, window, cx))))? + }) + } + + fn serialize( + &mut self, + _: &mut Workspace, + _: workspace::ItemId, + _: bool, + _: &mut Window, + _: &mut Context, + ) -> Option>> { + Some(Task::ready(Ok(()))) + } + + fn should_serialize(&self, _: &Self::Event) -> bool { + false + } +} + +impl Render for UnstagedDiff { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + self.diff.clone() + } +} + +pub struct UnstagedDiffToolbar { + unstaged_diff: Option>, + workspace: WeakEntity, +} + +impl UnstagedDiffToolbar { + pub fn new(workspace: &Workspace, _: &mut Context) -> Self { + Self { + unstaged_diff: None, + workspace: workspace.weak_handle(), + } + } + + fn unstaged_diff(&self, _: &App) -> Option> { + self.unstaged_diff.as_ref()?.upgrade() + } + + fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context) { + if let Some(unstaged_diff) = self.unstaged_diff(cx) { + unstaged_diff.focus_handle(cx).focus(window, cx); + } + let action = action.boxed_clone(); + cx.defer(move |cx| { + cx.dispatch_action(action.as_ref()); + }) + } + + fn stage_selected_unstaged_hunks( + &mut self, + move_to_next: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(unstaged_diff) = self.unstaged_diff(cx) else { + return; + }; + unstaged_diff.update(cx, |unstaged_diff, cx| { + unstaged_diff.stage_selected_unstaged_hunks(move_to_next, window, cx); + }); + } + + fn stage_all(&mut self, window: &mut Window, cx: &mut Context) { + self.workspace + .update(cx, |workspace, cx| { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + panel.update(cx, |panel, cx| { + panel.stage_all(&Default::default(), window, cx); + }); + }) + .ok(); + } +} + +impl EventEmitter for UnstagedDiffToolbar {} + +impl ToolbarItemView for UnstagedDiffToolbar { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _: &mut Window, + cx: &mut Context, + ) -> ToolbarItemLocation { + self.unstaged_diff = active_pane_item + .and_then(|item| item.act_as::(cx)) + .map(|entity| entity.downgrade()); + if self.unstaged_diff.is_some() { + ToolbarItemLocation::PrimaryRight + } else { + ToolbarItemLocation::Hidden + } + } + + fn pane_focus_update( + &mut self, + _pane_focused: bool, + _window: &mut Window, + _cx: &mut Context, + ) { + } +} + +impl Render for UnstagedDiffToolbar { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(unstaged_diff) = self.unstaged_diff(cx) else { + return div(); + }; + let focus_handle = unstaged_diff.focus_handle(cx); + let button_states = unstaged_diff.read(cx).button_states(cx); + + let diff = unstaged_diff.read(cx).diff.read(cx); + let (additions, deletions) = diff.calculate_changed_lines(cx); + let is_multibuffer_empty = diff.multibuffer().read(cx).is_empty(); + + h_flex() + .my_neg_1() + .py_1() + .gap_1p5() + .flex_wrap() + .justify_between() + .when(!is_multibuffer_empty, |this| { + this.child(DiffStat::new( + "unstaged-diff-stat", + additions as usize, + deletions as usize, + )) + .child(Divider::vertical().ml_1()) + }) + // n.b. the only reason these arrows are here is because we don't + // support "undo" for staging so we need a way to go back. + .child( + h_group_sm() + .child( + IconButton::new("up", IconName::ArrowUp) + .icon_size(IconSize::Small) + .disabled(!button_states.prev_next) + .tooltip(Tooltip::for_action_title_in( + "Go to Previous Hunk", + &GoToPreviousHunk, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&GoToPreviousHunk, window, cx) + })), + ) + .child( + IconButton::new("down", IconName::ArrowDown) + .icon_size(IconSize::Small) + .disabled(!button_states.prev_next) + .tooltip(Tooltip::for_action_title_in( + "Go to Next Hunk", + &GoToHunk, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.dispatch_action(&GoToHunk, window, cx) + })), + ), + ) + .child(Divider::vertical()) + .child( + h_group_sm() + .when(button_states.selection, |this| { + this.child( + Button::new("stage", "Stage") + .disabled(!button_states.stage) + .tooltip(Tooltip::text("Stage Selected Hunks")) + .on_click(cx.listener(|this, _, window, cx| { + this.stage_selected_unstaged_hunks(false, window, cx) + })), + ) + }) + .when(!button_states.selection, |this| { + this.child( + Button::new("stage", "Stage") + .disabled(!button_states.stage) + .tooltip(Tooltip::for_action_title_in( + "Stage and Go to Next Hunk", + &StageAndNext, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.stage_selected_unstaged_hunks(true, window, cx) + })), + ) + }), + ) + .child(Divider::vertical()) + .child( + Button::new("stage-all", "Stage All") + .width(rems_from_px(80.)) + .disabled(!button_states.stage_all) + .tooltip(Tooltip::for_action_title_in( + "Stage All Changes", + &StageAll, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| this.stage_all(window, cx))), + ) + } +} diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 5b61e6237e7..805ffc73550 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -626,7 +626,7 @@ impl DiffState { this.buffer_diff_changed(diff, range, cx); cx.emit(Event::BufferDiffChanged); } - BufferDiffEvent::BaseTextChanged | BufferDiffEvent::HunksStagedOrUnstaged(_) => {} + BufferDiffEvent::BaseTextChanged => {} }), diff, main_buffer: None, @@ -660,8 +660,7 @@ impl DiffState { ); cx.emit(Event::BufferDiffChanged); } - BufferDiffEvent::BaseTextChanged - | BufferDiffEvent::HunksStagedOrUnstaged(_) => {} + BufferDiffEvent::BaseTextChanged => {} } } }), diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index fd3efdae5c8..74efd3f0c86 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -1,5 +1,5 @@ -pub mod branch_diff; mod conflict_set; +pub mod diff_buffer_list; pub mod git_traversal; pub mod job_debug_queue; pub mod pending_op; @@ -15,7 +15,7 @@ use crate::{ }; use anyhow::{Context as _, Result, anyhow, bail}; use askpass::{AskPassDelegate, EncryptedPassword, IKnowWhatIAmDoingAndIHaveReadTheDocs}; -use buffer_diff::{BufferDiff, BufferDiffEvent}; +use buffer_diff::{BufferDiff, DiffHunk, DiffHunkSecondaryStatus, PendingHunk, PendingSense}; use client::ProjectId; use collections::HashMap; pub use conflict_set::{ConflictRegion, ConflictSet, ConflictSetSnapshot, ConflictSetUpdate}; @@ -51,7 +51,7 @@ use gpui::{ Subscription, Task, TaskExt, WeakEntity, }; use language::{ - Buffer, BufferEvent, Capability, Language, LanguageRegistry, + Anchor, Buffer, BufferEvent, Capability, Language, LanguageRegistry, proto::{deserialize_version, serialize_version}, }; use parking_lot::Mutex; @@ -82,7 +82,7 @@ use std::{ }; use sum_tree::{Edit, SumTree, TreeMap}; use task::Shell; -use text::{Bias, BufferId}; +use text::{Bias, BufferId, OffsetRangeExt, Rope, ToOffset}; use util::{ ResultExt, debug_panic, paths::{PathStyle, SanitizedPath}, @@ -106,6 +106,7 @@ pub struct GitStore { loading_diffs: HashMap<(BufferId, DiffKind), Shared, Arc>>>>, diffs: HashMap>, + buffer_ids_by_index_text_buffer_id: HashMap, shared_diffs: HashMap>, _subscriptions: Vec, } @@ -142,6 +143,16 @@ struct BufferGitState { head_text: Option>, index_text: Option>, + /// The optimistic, in-flight index state: the sole input to the index write, + /// expressed relative to the currently-loaded index text (`I0`). Never shown + /// to any view (views use per-diff pending hunks). Cleared when a + /// recalculation settles. + /// + /// `I0` is immutable for the lifetime of any pending edit (the index text + /// buffer is only fast-forwarded when a recalculation settles, which clears + /// this state in the same update), so byte offsets stay valid for the whole + /// window. + pending_index_edits: Option, Arc)>>, oid_texts: HashMap>, head_text_buffer: WeakEntity, index_text_buffer: WeakEntity, @@ -151,6 +162,24 @@ struct BufferGitState { language_changed: bool, } +fn pending_hunks( + hunks: &[DiffHunk], + version: &clock::Global, + sense: PendingSense, +) -> Vec { + hunks + .iter() + .map(|hunk| { + PendingHunk::new( + hunk.buffer_range.clone(), + hunk.diff_base_byte_range.clone(), + version.clone(), + sense, + ) + }) + .collect() +} + #[derive(Clone, Debug)] enum DiffBasesChange { SetIndex(Option), @@ -170,6 +199,73 @@ enum DiffKind { SinceOid(Option), } +struct IndexTextFile { + path: Arc, + full_path: PathBuf, + path_style: PathStyle, + file_name: String, + worktree_id: WorktreeId, + is_private: bool, +} + +impl IndexTextFile { + fn new(file: &dyn language::File, cx: &App) -> Self { + Self { + path: file.path().clone(), + full_path: file.full_path(cx), + path_style: file.path_style(cx), + file_name: file.file_name(cx).to_string(), + worktree_id: file.worktree_id(cx), + is_private: file.is_private(), + } + } +} + +impl language::File for IndexTextFile { + fn as_local(&self) -> Option<&dyn language::LocalFile> { + None + } + + fn disk_state(&self) -> language::DiskState { + language::DiskState::Historic { was_deleted: false } + } + + fn path(&self) -> &Arc { + &self.path + } + + fn full_path(&self, _: &App) -> PathBuf { + self.full_path.clone() + } + + fn path_style(&self, _: &App) -> PathStyle { + self.path_style + } + + fn file_name<'a>(&'a self, _: &'a App) -> &'a str { + &self.file_name + } + + fn worktree_id(&self, _: &App) -> WorktreeId { + self.worktree_id + } + + fn to_proto(&self, _: &App) -> rpc::proto::File { + rpc::proto::File { + worktree_id: self.worktree_id.to_proto(), + entry_id: None, + path: self.path.as_ref().to_proto(), + mtime: None, + is_deleted: false, + is_historic: true, + } + } + + fn is_private(&self) -> bool { + self.is_private + } +} + #[derive(Debug, Clone, Copy)] pub enum GitAccess { /// Either: @@ -648,6 +744,7 @@ impl GitStore { loading_diffs: HashMap::default(), shared_diffs: HashMap::default(), diffs: HashMap::default(), + buffer_ids_by_index_text_buffer_id: HashMap::default(), } } @@ -897,6 +994,16 @@ impl GitStore { .as_ref() .and_then(|weak| weak.upgrade()) { + // If this unstaged diff was first opened as the uncommitted diff's + // secondary, its index text wasn't highlighted. Enable it now and + // recalc so the language gets applied to the deleted (base) side. + diff_state.update(cx, |diff_state, cx| { + if !diff_state.index_text_buffer_language_enabled { + diff_state.index_text_buffer_language_enabled = true; + let buffer_snapshot = buffer.read(cx).text_snapshot(); + diff_state.recalculate_diffs(buffer_snapshot, cx); + } + }); if let Some(task) = diff_state.update(cx, |diff_state, _| diff_state.wait_for_recalculation()) { @@ -944,15 +1051,17 @@ impl GitStore { cx.background_spawn(async move { task.await.map_err(|e| anyhow!("{e}")) }) } + /// Opens the staged (HEAD-vs-index) diff for the given buffer, along with + /// the index text buffer that is the diff's main buffer. pub fn open_staged_diff( &mut self, buffer: Entity, cx: &mut Context, - ) -> Task>> { + ) -> Task, Entity)>> { let buffer_id = buffer.read(cx).remote_id(); if let Some(diff_state) = self.diffs.get(&buffer_id) - && let Some(staged_diff) = diff_state.read(cx).staged_diff() + && let Some(staged_diff) = diff_state.read(cx).staged_diff_and_index_text_buffer() { if let Some(task) = diff_state.update(cx, |diff_state, _| diff_state.wait_for_recalculation()) @@ -988,7 +1097,270 @@ impl GitStore { }) .clone(); - cx.background_spawn(async move { task.await.map_err(|e| anyhow!("{e}")) }) + cx.spawn(async move |this, cx| { + let diff = task.await.map_err(|e| anyhow!("{e}"))?; + this.update(cx, |this, cx| { + let index_text_buffer = this + .diffs + .get(&buffer_id) + .and_then(|diff_state| { + let (_, index_text_buffer) = diff_state.read(cx).staged_diff.as_ref()?; + Some(index_text_buffer.clone()) + }) + .context("index text buffer missing after opening staged diff")?; + Ok((diff, index_text_buffer)) + })? + }) + } + + /// Stages the worktree changes covered by `worktree_ranges`, acting on the + /// given unstaged (index-vs-worktree) diff. Used by both the unstaged-changes + /// view and the uncommitted (gutter) controls: "stage" means the same index + /// change regardless of which view it was invoked from, so callers holding an + /// uncommitted diff pass its unstaged secondary. + /// + /// Decomposes the worktree region into the unstaged hunks it covers, so no + /// worktree->index projection is needed. Optimistically suppresses the staged + /// hunks from the unstaged diff and, if the uncommitted diff happens to be + /// open, marks the corresponding uncommitted hunks as staging. + pub fn stage_hunks( + &mut self, + buffer: Entity, + unstaged_diff: Entity, + worktree_ranges: Vec>, + cx: &mut Context, + ) -> Result<()> { + if worktree_ranges.is_empty() { + return Ok(()); + } + let buffer_snapshot = buffer.read(cx).snapshot(); + let buffer_id = buffer_snapshot.remote_id(); + let file_exists = buffer_snapshot + .file() + .is_some_and(|file| file.disk_state().exists()); + + let unstaged_snapshot = unstaged_diff.read(cx).snapshot(cx); + + // Decompose: the unstaged hunks the worktree region covers carry the + // index range directly. Sorting by buffer offset also sorts by index + // offset, since the hunks are non-overlapping. We read the raw hunks + // (ignoring optimistic suppression) so that re-staging a hunk that was + // optimistically staged and then unstaged still finds it. The footprints + // let the patch drop earlier edits the region covers even where the raw + // diff is empty (re-staging a hunk that is staged on disk but + // optimistically unstaged). + let mut unstaged_hunks = Vec::new(); + let mut index_footprints = Vec::new(); + for range in &worktree_ranges { + unstaged_hunks.extend( + unstaged_snapshot.raw_hunks_intersecting_range(range.clone(), &buffer_snapshot), + ); + index_footprints.push( + unstaged_snapshot.base_text_range_for_buffer_range(range.clone(), &buffer_snapshot), + ); + } + unstaged_hunks.sort_by_key(|hunk| hunk.buffer_range.start.to_offset(&buffer_snapshot)); + unstaged_hunks.dedup_by(|a, b| a.buffer_range.start == b.buffer_range.start); + + // The uncommitted hunks the region covers get the optimistic + // "staging" secondary status (free cross-view update). + let uncommitted_diff = self.get_uncommitted_diff(buffer_id, cx); + let mut uncommitted_hunks = Vec::new(); + if let Some(uncommitted_diff) = &uncommitted_diff { + let uncommitted_snapshot = uncommitted_diff.read(cx).snapshot(cx); + for range in &worktree_ranges { + uncommitted_hunks.extend( + uncommitted_snapshot + .hunks_intersecting_range(range.clone(), &buffer_snapshot) + .filter(|hunk| { + hunk.secondary_status != DiffHunkSecondaryStatus::NoSecondaryHunk + }), + ); + } + uncommitted_hunks + .sort_by_key(|hunk| hunk.buffer_range.start.to_offset(&buffer_snapshot)); + uncommitted_hunks.dedup_by(|a, b| a.buffer_range.start == b.buffer_range.start); + } + + let index_edits = if !file_exists { + // The worktree file is gone: staging removes it from the index. + None + } else { + Some( + unstaged_hunks + .iter() + .map(|hunk| { + let worktree_range = hunk.buffer_range.to_offset(&buffer_snapshot); + let replacement: Arc = Arc::from( + buffer_snapshot + .text_for_range(worktree_range) + .collect::(), + ); + (hunk.diff_base_byte_range.clone(), replacement) + }) + .collect::>(), + ) + }; + + let version = buffer_snapshot.version().clone(); + let unstaged_pending = pending_hunks(&unstaged_hunks, &version, PendingSense::Suppress); + let uncommitted_pending = pending_hunks( + &uncommitted_hunks, + &version, + PendingSense::SetSecondaryStatus { stage: true }, + ); + drop(unstaged_snapshot); + + let diff_state = self + .diffs + .get(&buffer_id) + .cloned() + .context("failed to find git state for buffer")?; + diff_state.update(cx, |diff_state, _| { + diff_state.remove_overlapping_pending_index_edits(&index_footprints); + diff_state.insert_pending_index_edits(index_edits); + }); + + if let Some(uncommitted_diff) = uncommitted_diff { + uncommitted_diff.update(cx, |diff, cx| { + diff.set_pending_hunks(&uncommitted_pending, &buffer_snapshot, cx); + }); + } + unstaged_diff.update(cx, |diff, cx| { + diff.set_pending_hunks(&unstaged_pending, &buffer_snapshot, cx); + }); + + self.write_optimistic_index(buffer_id, cx); + Ok(()) + } + + /// Unstages the worktree changes covered by `worktree_ranges`, acting on the + /// given uncommitted (HEAD-vs-worktree) diff, invoked from the uncommitted + /// (gutter) controls. Uses the worktree->index projection (the hard part) + /// because the acted-on hunks are HEAD-vs-worktree. + pub fn unstage_uncommitted_hunks( + &mut self, + buffer: Entity, + uncommitted_diff: Entity, + worktree_ranges: Vec>, + cx: &mut Context, + ) -> Result<()> { + if worktree_ranges.is_empty() { + return Ok(()); + } + let buffer_snapshot = buffer.read(cx).snapshot(); + let buffer_id = buffer_snapshot.remote_id(); + let file_exists = buffer_snapshot + .file() + .is_some_and(|file| file.disk_state().exists()); + + let uncommitted_snapshot = uncommitted_diff.read(cx).snapshot(cx); + let unstaged_snapshot = uncommitted_snapshot + .secondary_diff() + .context("diff has no unstaged secondary")?; + + let mut hunks = Vec::new(); + for range in &worktree_ranges { + hunks.extend( + uncommitted_snapshot.hunks_intersecting_range(range.clone(), &buffer_snapshot), + ); + } + hunks.sort_by_key(|hunk| hunk.buffer_range.start.to_offset(&buffer_snapshot)); + hunks.dedup_by(|a, b| a.buffer_range.start == b.buffer_range.start); + + let (index_edits, pending) = uncommitted_snapshot.compute_uncommitted_index_edits( + unstaged_snapshot, + false, + &hunks, + &buffer_snapshot, + file_exists, + ); + drop(uncommitted_snapshot); + + let diff_state = self + .diffs + .get(&buffer_id) + .cloned() + .context("failed to find git state for buffer")?; + diff_state.update(cx, |diff_state, _| { + diff_state.insert_pending_index_edits(index_edits) + }); + + uncommitted_diff.update(cx, |diff, cx| { + diff.set_pending_hunks(&pending, &buffer_snapshot, cx); + }); + + self.write_optimistic_index(buffer_id, cx); + Ok(()) + } + + /// Unstages staged (HEAD-vs-index) hunks covered by `index_ranges` (in the + /// index text buffer's coordinates), acting on the given staged diff, + /// invoked from the staged-changes view. The acted-on hunks already carry + /// an index range, so no projection is needed; optimistically suppresses + /// them from the staged diff. + pub fn unstage_staged_hunks( + &mut self, + staged_diff: Entity, + index_ranges: Vec>, + cx: &mut Context, + ) -> Result<()> { + if index_ranges.is_empty() { + return Ok(()); + } + let index_buffer_id = staged_diff.read(cx).buffer_id; + let buffer_id = *self + .buffer_ids_by_index_text_buffer_id + .get(&index_buffer_id) + .context("failed to find git state for index text buffer")?; + let diff_state = self + .diffs + .get(&buffer_id) + .cloned() + .context("failed to find git state for buffer")?; + let index_buffer = diff_state + .read(cx) + .index_text_buffer() + .context("index text is not loaded")?; + let index_snapshot = index_buffer.read(cx).text_snapshot(); + let staged_snapshot = staged_diff.read(cx).snapshot(cx); + + let mut hunks = Vec::new(); + for range in &index_ranges { + hunks.extend(staged_snapshot.hunks_intersecting_range(range.clone(), &index_snapshot)); + } + hunks.sort_by_key(|hunk| hunk.buffer_range.start.to_offset(&index_snapshot)); + hunks.dedup_by(|a, b| a.buffer_range.start == b.buffer_range.start); + + let index_edits = staged_diff + .read(cx) + .unstage_staged_hunks(&hunks, &index_snapshot); + let version = index_snapshot.version().clone(); + let pending = pending_hunks(&hunks, &version, PendingSense::Suppress); + drop(staged_snapshot); + + diff_state.update(cx, |diff_state, _| { + diff_state.insert_pending_index_edits(index_edits) + }); + staged_diff.update(cx, |diff, cx| { + diff.set_pending_hunks(&pending, &index_snapshot, cx); + }); + + self.write_optimistic_index(buffer_id, cx); + Ok(()) + } + + /// Derives the desired index text from the buffer's optimistic patch and + /// schedules the write. + fn write_optimistic_index(&mut self, buffer_id: BufferId, cx: &mut Context) { + let Some(diff_state) = self.diffs.get(&buffer_id) else { + return; + }; + let new_index_text = diff_state + .read(cx) + .pending_index_text(cx) + .map(|rope| rope.to_string()); + self.write_index_text_for_buffer_id(buffer_id, new_index_text, cx); } pub fn open_diff_since( @@ -1058,9 +1430,6 @@ impl GitStore { }); this.update(cx, |this, cx| { - cx.subscribe(&buffer_diff, Self::on_buffer_diff_event) - .detach(); - this.loading_diffs.remove(&(buffer_id, diff_kind)); let git_store = cx.weak_entity(); @@ -1174,6 +1543,9 @@ impl GitStore { let buffer_id = buffer.remote_id(); let language = buffer.language().cloned(); let language_registry = buffer.language_registry(); + let index_text_file = buffer.file().map(|file| { + Arc::new(IndexTextFile::new(file.as_ref(), cx)) as Arc + }); let text_snapshot = buffer.text_snapshot(); this.loading_diffs.remove(&(buffer_id, kind)); @@ -1194,7 +1566,7 @@ impl GitStore { let diff = match kind { DiffKind::Unstaged => { let base_text_buffer = diff_state.update(cx, |diff_state, cx| { - diff_state.get_or_create_index_text_buffer(cx) + diff_state.get_or_create_index_text_buffer(index_text_file.clone(), cx) }); cx.new(|cx| { BufferDiff::new_with_base_text_buffer( @@ -1208,7 +1580,10 @@ impl GitStore { let (index_text_buffer, base_text_buffer) = diff_state.update(cx, |diff_state, cx| { ( - diff_state.get_or_create_index_text_buffer(cx), + diff_state.get_or_create_index_text_buffer( + index_text_file.clone(), + cx, + ), diff_state.get_or_create_head_text_buffer(cx), ) }); @@ -1244,16 +1619,19 @@ impl GitStore { unreachable!("open_diff_internal is not used for OID diffs") } }; - cx.subscribe(&diff, Self::on_buffer_diff_event).detach(); diff }; - diff_state.update(cx, |diff_state, cx| { + let rx = diff_state.update(cx, |diff_state, cx| { diff_state.language = language; diff_state.language_registry = language_registry; match kind { DiffKind::Unstaged => { diff_state.unstaged_diff = Some(diff.downgrade()); + // The deleted (base) side of a standalone unstaged diff + // is the index, so highlight it. The recalc kicked off by + // `diff_bases_changed` below applies the language. + diff_state.index_text_buffer_language_enabled = true; } DiffKind::Staged => { diff_state.index_text_buffer_language_enabled = true; @@ -1266,7 +1644,8 @@ impl GitStore { let unstaged_diff = if let Some(diff) = existing_unstaged_diff { diff } else { - let base_text_buffer = diff_state.get_or_create_index_text_buffer(cx); + let base_text_buffer = + diff_state.get_or_create_index_text_buffer(index_text_file, cx); let unstaged_diff = cx.new(|cx| { BufferDiff::new_with_base_text_buffer( &text_snapshot, @@ -1295,7 +1674,15 @@ impl GitStore { } Ok(diff) }) - }) + }); + let diff_state = this.diffs.get(&buffer_id).cloned(); + if let Some(index_text_buffer) = + diff_state.and_then(|diff_state| diff_state.read(cx).index_text_buffer()) + { + this.buffer_ids_by_index_text_buffer_id + .insert(index_text_buffer.read(cx).remote_id(), buffer_id); + } + rx })?? .await } @@ -1997,6 +2384,8 @@ impl GitStore { } BufferStoreEvent::BufferDropped(buffer_id) => { self.diffs.remove(buffer_id); + self.buffer_ids_by_index_text_buffer_id + .retain(|_, main_buffer_id| main_buffer_id != buffer_id); for diffs in self.shared_diffs.values_mut() { diffs.remove(buffer_id); } @@ -2073,48 +2462,45 @@ impl GitStore { } } - fn on_buffer_diff_event( + fn write_index_text_for_buffer_id( &mut self, - diff: Entity, - event: &BufferDiffEvent, + buffer_id: BufferId, + new_index_text: Option, cx: &mut Context, ) { - 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 - }); - if let Some((repo, path)) = self.repository_and_path_for_buffer_id(buffer_id, cx) { - let recv = repo.update(cx, |repo, cx| { - log::debug!("hunks changed for {}", path.as_unix_str()); - repo.spawn_set_index_text_job( - path, - new_index_text, - Some(hunk_staging_operation_count), - cx, - ) - }); - let diff = diff.downgrade(); - cx.spawn(async move |this, cx| { - if let Ok(Err(error)) = cx.background_spawn(recv).await { - diff.update(cx, |diff, cx| { - diff.clear_pending_hunks(cx); - }) - .ok(); - this.update(cx, |_, cx| cx.emit(GitStoreEvent::IndexWriteError(error))) - .ok(); - } - }) - .detach(); - } + let Some(diff_state) = self.diffs.get(&buffer_id) else { + 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 + }); + let Some((repo, path)) = self.repository_and_path_for_buffer_id(buffer_id, cx) else { + return; + }; + let recv = repo.update(cx, |repo, cx| { + log::debug!("hunks changed for {}", path.as_unix_str()); + repo.spawn_set_index_text_job( + path, + new_index_text, + Some(hunk_staging_operation_count), + cx, + ) + }); + cx.spawn(async move |this, cx| { + if let Ok(Err(error)) = cx.background_spawn(recv).await { + this.update(cx, |this, cx| { + if let Some(diff_state) = this.diffs.get(&buffer_id).cloned() { + diff_state.update(cx, |diff_state, cx| { + diff_state.clear_pending_index_edits_and_hunks(cx); + }); + } + cx.emit(GitStoreEvent::IndexWriteError(error)); + }) + .ok(); } - } + }) + .detach(); } fn local_worktree_git_repos_changed( @@ -3951,6 +4337,7 @@ impl BufferGitState { hunk_staging_operation_count_as_of_write: 0, head_text: Default::default(), index_text: Default::default(), + pending_index_edits: Some(Vec::new()), oid_texts: Default::default(), head_text_buffer: WeakEntity::new_invalid(), index_text_buffer: WeakEntity::new_invalid(), @@ -3978,13 +4365,27 @@ impl BufferGitState { buffer } - fn get_or_create_index_text_buffer(&mut self, cx: &mut Context) -> Entity { + fn index_text_buffer(&self) -> Option> { + self.index_text_buffer.upgrade() + } + + fn get_or_create_index_text_buffer( + &mut self, + file: Option>, + cx: &mut Context, + ) -> Entity { if let Some(buffer) = self.index_text_buffer.upgrade() { + if let Some(file) = file { + buffer.update(cx, |buffer, cx| buffer.file_updated(file, cx)); + } return buffer; } let index_text = self.index_text.clone(); let buffer = cx.new(|cx| { let mut buffer = Buffer::local(index_text.as_deref().unwrap_or(""), cx); + if let Some(file) = file { + buffer.file_updated(file, cx); + } buffer.set_capability(Capability::ReadOnly, cx); buffer }); @@ -4055,6 +4456,11 @@ impl BufferGitState { self.staged_diff.as_ref().and_then(|(set, _)| set.upgrade()) } + fn staged_diff_and_index_text_buffer(&self) -> Option<(Entity, Entity)> { + let (diff, index_text_buffer) = self.staged_diff.as_ref()?; + Some((diff.upgrade()?, index_text_buffer.clone())) + } + fn uncommitted_diff(&self) -> Option> { self.uncommitted_diff.as_ref().and_then(|set| set.upgrade()) } @@ -4076,6 +4482,100 @@ impl BufferGitState { } } + fn remove_overlapping_pending_index_edits(&mut self, ranges: &[Range]) { + if let Some(edits) = &mut self.pending_index_edits { + edits.retain(|(existing, _)| { + ranges.iter().all(|footprint| { + existing.end < footprint.start || footprint.end < existing.start + }) + }); + } + } + + fn insert_pending_index_edits(&mut self, edits: Option, Arc)>>) { + match edits { + None => { + self.pending_index_edits = None; + } + Some(new_edits) => { + let mut edits = self.pending_index_edits.take().unwrap_or_default(); + for (range, replacement) in new_edits { + edits.retain(|(existing, _)| { + existing.end < range.start || range.end < existing.start + }); + let position = + edits.partition_point(|(existing, _)| existing.start < range.start); + edits.insert(position, (range, replacement)); + } + self.pending_index_edits = Some(edits); + } + } + } + + fn pending_index_text(&self, cx: &App) -> Option { + let index_text_buffer = self.index_text_buffer.upgrade()?; + let edits = self.pending_index_edits.as_ref()?; + #[cfg(debug_assertions)] + for window in edits.windows(2) { + debug_assert!(window[0].0.end <= window[1].0.start); + } + let mut index_text = index_text_buffer.read(cx).text_snapshot().as_rope().clone(); + for (old_range, replacement_text) in edits.iter().rev() { + index_text.replace(old_range.clone(), replacement_text); + } + Some(index_text) + } + + fn clear_pending_index_edits(&mut self) { + self.pending_index_edits = Some(Vec::new()); + } + + fn clear_pending_hunks(&mut self, cx: &mut Context) { + for diff in [ + self.uncommitted_diff(), + self.unstaged_diff(), + self.staged_diff(), + ] + .into_iter() + .flatten() + { + diff.update(cx, |diff, cx| diff.clear_pending_hunks(cx)); + } + } + + fn mark_whole_file_stage_or_unstage_pending( + &mut self, + stage: bool, + buffer_snapshot: &text::BufferSnapshot, + cx: &mut Context, + ) { + if let Some(uncommitted_diff) = self.uncommitted_diff() { + uncommitted_diff.update(cx, |uncommitted_diff, cx| { + uncommitted_diff.mark_all_hunks_pending(stage, buffer_snapshot, cx); + }); + } + + if stage { + if let Some(unstaged_diff) = self.unstaged_diff() { + unstaged_diff.update(cx, |unstaged_diff, cx| { + unstaged_diff.suppress_all_hunks_pending(buffer_snapshot, cx); + }); + } + } else if let Some(staged_diff) = self.staged_diff() + && let Some(index_text_buffer) = self.index_text_buffer() + { + let index_snapshot = index_text_buffer.read(cx).text_snapshot(); + staged_diff.update(cx, |staged_diff, cx| { + staged_diff.suppress_all_hunks_pending(&index_snapshot, cx); + }); + } + } + + fn clear_pending_index_edits_and_hunks(&mut self, cx: &mut Context) { + self.clear_pending_index_edits(); + self.clear_pending_hunks(cx); + } + fn handle_base_texts_updated( &mut self, buffer: text::BufferSnapshot, @@ -4089,16 +4589,17 @@ impl BufferGitState { }; let diff_bases_change = match mode { - Mode::HeadOnly => DiffBasesChange::SetHead(message.committed_text), - Mode::IndexOnly => DiffBasesChange::SetIndex(message.staged_text), - Mode::IndexMatchesHead => DiffBasesChange::SetBoth(message.committed_text), - Mode::IndexAndHead => DiffBasesChange::SetEach { + Mode::HeadOnly => Some(DiffBasesChange::SetHead(message.committed_text)), + Mode::IndexOnly => Some(DiffBasesChange::SetIndex(message.staged_text)), + Mode::IndexMatchesHead => Some(DiffBasesChange::SetBoth(message.committed_text)), + Mode::IndexAndHead => Some(DiffBasesChange::SetEach { index: message.staged_text, head: message.committed_text, - }, + }), + Mode::Unchanged => None, }; - self.diff_bases_changed(buffer, Some(diff_bases_change), cx); + self.diff_bases_changed(buffer, diff_bases_change, cx); } pub fn wait_for_recalculation(&mut self) -> Option + use<>> { @@ -4403,7 +4904,9 @@ impl BufferGitState { return Ok(()); } - this.update(cx, |_, cx| { + this.update(cx, |this, cx| { + this.clear_pending_index_edits(); + if let (Some(staged_diff), Some(new_staged_diff)) = (staged_diff.as_ref(), new_staged_diff.clone()) { @@ -4422,7 +4925,7 @@ impl BufferGitState { head_text_buffer.fast_forward(edited_head_text, cx) }); } - diff.set_snapshot(new_staged_diff, cx) + diff.set_snapshot_with_secondary(new_staged_diff, None, true, cx) }); } @@ -4437,7 +4940,7 @@ impl BufferGitState { index_text_buffer.fast_forward(edited_index_text, cx) }); } - diff.set_snapshot(new_unstaged_diff, cx) + diff.set_snapshot_with_secondary(new_unstaged_diff, None, true, cx) })) } else { None @@ -5244,21 +5747,23 @@ impl Repository { diff_state.update(cx, |diff_state, cx| { use proto::update_diff_bases::Mode; - if let Some((diff_bases_change, (client, project_id))) = - diff_bases_change.clone().zip(downstream_client) - { - let (staged_text, committed_text, mode) = match diff_bases_change { - DiffBasesChange::SetIndex(index) => { - (index, None, Mode::IndexOnly) - } - DiffBasesChange::SetHead(head) => (None, head, Mode::HeadOnly), - DiffBasesChange::SetEach { index, head } => { - (index, head, Mode::IndexAndHead) - } - DiffBasesChange::SetBoth(text) => { - (None, text, Mode::IndexMatchesHead) - } - }; + if let Some((client, project_id)) = downstream_client { + let (staged_text, committed_text, mode) = + match diff_bases_change.clone() { + Some(DiffBasesChange::SetIndex(index)) => { + (index, None, Mode::IndexOnly) + } + Some(DiffBasesChange::SetHead(head)) => { + (None, head, Mode::HeadOnly) + } + Some(DiffBasesChange::SetEach { index, head }) => { + (index, head, Mode::IndexAndHead) + } + Some(DiffBasesChange::SetBoth(text)) => { + (None, text, Mode::IndexMatchesHead) + } + None => (None, None, Mode::Unchanged), + }; client .send(proto::UpdateDiffBases { project_id: project_id.to_proto(), @@ -6454,33 +6959,15 @@ impl Repository { else { continue; }; - let Some(uncommitted_diff) = - diff_state.read(cx).uncommitted_diff.as_ref().and_then( - |uncommitted_diff| uncommitted_diff.upgrade(), - ) - else { - continue; - }; let buffer_snapshot = buffer.read(cx).text_snapshot(); - let file_exists = buffer - .read(cx) - .file() - .is_some_and(|file| file.disk_state().exists()); let hunk_staging_operation_count = diff_state.update(cx, |diff_state, cx| { - uncommitted_diff.update( - cx, - |uncommitted_diff, cx| { - uncommitted_diff - .stage_or_unstage_all_hunks( - stage, - &buffer_snapshot, - file_exists, - cx, - ); - }, - ); - + diff_state + .mark_whole_file_stage_or_unstage_pending( + stage, + &buffer_snapshot, + cx, + ); diff_state.hunk_staging_operation_count += 1; diff_state.hunk_staging_operation_count }); @@ -6547,14 +7034,8 @@ impl Repository { if result.is_ok() { diff_state.hunk_staging_operation_count_as_of_write = hunk_staging_operation_count; - } else if let Some(uncommitted_diff) = - &diff_state.uncommitted_diff - { - uncommitted_diff - .update(cx, |uncommitted_diff, cx| { - uncommitted_diff.clear_pending_hunks(cx); - }) - .ok(); + } else { + diff_state.clear_pending_hunks(cx); } }) .ok(); diff --git a/crates/project/src/git_store/branch_diff.rs b/crates/project/src/git_store/diff_buffer_list.rs similarity index 76% rename from crates/project/src/git_store/branch_diff.rs rename to crates/project/src/git_store/diff_buffer_list.rs index 67aa198945d..2f1586d3e04 100644 --- a/crates/project/src/git_store/branch_diff.rs +++ b/crates/project/src/git_store/diff_buffer_list.rs @@ -24,6 +24,8 @@ use crate::{ #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum DiffBase { Head, + Index, + Staged, Merge { base_ref: SharedString }, } @@ -33,7 +35,7 @@ impl DiffBase { } } -pub struct BranchDiff { +pub struct DiffBufferList { diff_base: DiffBase, repo: Option>, project: Entity, @@ -52,9 +54,9 @@ pub enum BranchDiffEvent { DiffBaseChanged, } -impl EventEmitter for BranchDiff {} +impl EventEmitter for DiffBufferList {} -impl BranchDiff { +impl DiffBufferList { pub fn new( source: DiffBase, project: Entity, @@ -350,8 +352,13 @@ impl BranchDiff { .as_ref() .and_then(|t| t.entries.get(&item.repo_path)) .cloned(); - let Some(status) = self.merge_statuses(Some(item.status), branch_diff.as_ref()) - else { + let Some(status) = (match self.diff_base { + DiffBase::Head | DiffBase::Merge { .. } => { + self.merge_statuses(Some(item.status), branch_diff.as_ref()) + } + DiffBase::Index => item.status.staging().has_unstaged().then_some(item.status), + DiffBase::Staged => item.status.staging().has_staged().then_some(item.status), + }) else { continue; }; if !status.has_changes() { @@ -363,7 +370,13 @@ impl BranchDiff { else { continue; }; - let task = Self::load_buffer(branch_diff, project_path, repo.clone(), cx); + let task = Self::load_buffer( + self.diff_base.clone(), + branch_diff, + project_path, + repo.clone(), + cx, + ); output.push(DiffBuffer { repo_path: item.repo_path.clone(), @@ -383,8 +396,13 @@ impl BranchDiff { let Some(project_path) = repo.read(cx).repo_path_to_project_path(&path, cx) else { continue; }; - let task = - Self::load_buffer(Some(branch_diff.clone()), project_path, repo.clone(), cx); + let task = Self::load_buffer( + self.diff_base.clone(), + Some(branch_diff.clone()), + project_path, + repo.clone(), + cx, + ); let file_status = diff_status_to_file_status(branch_diff); @@ -400,44 +418,87 @@ impl BranchDiff { #[instrument(skip_all)] fn load_buffer( + diff_base: DiffBase, branch_diff: Option, project_path: crate::ProjectPath, repo: Entity, cx: &Context<'_, Project>, - ) -> Task, Entity, Entity)>> { + ) -> Task> { let task = cx.spawn(async move |project, cx| { let buffer = project .update(cx, |project, cx| project.open_buffer(project_path, cx))? .await?; - let changes = if let Some(entry) = branch_diff { - let oid = match entry { - git::status::TreeDiffStatus::Added { .. } => None, - git::status::TreeDiffStatus::Modified { old, .. } - | git::status::TreeDiffStatus::Deleted { old } => Some(old), - }; - project - .update(cx, |project, cx| { - project.git_store().update(cx, |git_store, cx| { - git_store.open_diff_since(oid, buffer.clone(), repo, cx) - }) - })? - .await? - } else { - project - .update(cx, |project, cx| { - project.open_uncommitted_diff(buffer.clone(), cx) - })? - .await? + let main_buffer = buffer.clone(); + let load_conflict_set = diff_base != DiffBase::Staged; + let (display_buffer, changes) = match diff_base { + DiffBase::Head => { + let diff = project + .update(cx, |project, cx| { + project.open_uncommitted_diff(buffer.clone(), cx) + })? + .await?; + (buffer, diff) + } + DiffBase::Index => { + let diff = project + .update(cx, |project, cx| { + project.open_unstaged_diff(buffer.clone(), cx) + })? + .await?; + (buffer, diff) + } + DiffBase::Staged => { + let (diff, index_buffer) = project + .update(cx, |project, cx| { + project.open_staged_diff(buffer.clone(), cx) + })? + .await?; + (index_buffer, diff) + } + DiffBase::Merge { .. } => { + let diff = if let Some(entry) = branch_diff { + let oid = match entry { + git::status::TreeDiffStatus::Added { .. } => None, + git::status::TreeDiffStatus::Modified { old, .. } + | git::status::TreeDiffStatus::Deleted { old } => Some(old), + }; + project + .update(cx, |project, cx| { + project.git_store().update(cx, |git_store, cx| { + git_store.open_diff_since(oid, buffer.clone(), repo, cx) + }) + })? + .await? + } else { + project + .update(cx, |project, cx| { + project.open_uncommitted_diff(buffer.clone(), cx) + })? + .await? + }; + (buffer, diff) + } }; - let conflict_set = project - .update(cx, |project, cx| { - project.git_store().update(cx, |git_store, cx| { - git_store.open_conflict_set(buffer.clone(), cx) - }) - })? - .await; - Ok((buffer, changes, conflict_set)) + let conflict_set = if load_conflict_set { + Some( + project + .update(cx, |project, cx| { + project.git_store().update(cx, |git_store, cx| { + git_store.open_conflict_set(main_buffer.clone(), cx) + }) + })? + .await, + ) + } else { + None + }; + Ok(LoadedDiffBuffer { + display_buffer, + main_buffer, + diff: changes, + conflict_set, + }) }); task } @@ -461,9 +522,17 @@ fn diff_status_to_file_status(branch_diff: &git::status::TreeDiffStatus) -> File file_status } +#[derive(Debug)] +pub struct LoadedDiffBuffer { + pub display_buffer: Entity, + pub main_buffer: Entity, + pub diff: Entity, + pub conflict_set: Option>, +} + #[derive(Debug)] pub struct DiffBuffer { pub repo_path: RepoPath, pub file_status: FileStatus, - pub load: Task, Entity, Entity)>>, + pub load: Task>, } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 7043c243d83..6a08a291e31 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -3245,12 +3245,14 @@ impl Project { .update(cx, |git_store, cx| git_store.open_unstaged_diff(buffer, cx)) } + /// Opens the staged (HEAD-vs-index) diff for the given buffer, along with + /// the index text buffer that is the diff's main buffer. #[ztracing::instrument(skip_all)] pub fn open_staged_diff( &mut self, buffer: Entity, cx: &mut Context, - ) -> Task>> { + ) -> Task, Entity)>> { if self.is_disconnected(cx) { return Task::ready(Err(anyhow!(ErrorCode::Disconnected))); } @@ -3272,6 +3274,59 @@ impl Project { }) } + /// Stages the worktree changes covered by `worktree_ranges` (in the worktree + /// buffer's coordinates), acting on the given unstaged diff. Used by both the + /// unstaged-changes view and the uncommitted (gutter) controls. + pub fn stage_hunks( + &mut self, + buffer: Entity, + unstaged_diff: Entity, + worktree_ranges: Vec>, + cx: &mut Context, + ) -> Result<()> { + if self.is_disconnected(cx) { + return Err(anyhow!(ErrorCode::Disconnected)); + } + self.git_store.update(cx, |git_store, cx| { + git_store.stage_hunks(buffer, unstaged_diff, worktree_ranges, cx) + }) + } + + /// Unstages the worktree changes covered by `worktree_ranges` (in the worktree + /// buffer's coordinates), acting on the given uncommitted diff. Used by the + /// uncommitted (gutter) controls. + pub fn unstage_uncommitted_hunks( + &mut self, + buffer: Entity, + uncommitted_diff: Entity, + worktree_ranges: Vec>, + cx: &mut Context, + ) -> Result<()> { + if self.is_disconnected(cx) { + return Err(anyhow!(ErrorCode::Disconnected)); + } + self.git_store.update(cx, |git_store, cx| { + git_store.unstage_uncommitted_hunks(buffer, uncommitted_diff, worktree_ranges, cx) + }) + } + + /// Unstages the staged changes covered by `index_ranges` (in the index + /// buffer's coordinates), acting on the given staged diff. Used by the + /// staged-changes view. + pub fn unstage_staged_hunks( + &mut self, + staged_diff: Entity, + index_ranges: Vec>, + cx: &mut Context, + ) -> Result<()> { + if self.is_disconnected(cx) { + return Err(anyhow!(ErrorCode::Disconnected)); + } + self.git_store.update(cx, |git_store, cx| { + git_store.unstage_staged_hunks(staged_diff, index_ranges, cx) + }) + } + pub fn open_buffer_by_id( &mut self, id: BufferId, diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index da62617d286..7b4f5839e94 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -21,8 +21,7 @@ mod yarn; use anyhow::Result; use async_trait::async_trait; use buffer_diff::{ - BufferDiffEvent, DiffChanged, DiffHunkSecondaryStatus, DiffHunkStatus, DiffHunkStatusKind, - assert_hunks, + BufferDiff, DiffHunkSecondaryStatus, DiffHunkStatus, DiffHunkStatusKind, assert_hunks, }; use collections::{BTreeSet, HashMap, HashSet}; use encoding_rs; @@ -90,7 +89,7 @@ use task::{ResolvedTask, ShellKind, TaskContext}; use text::{Anchor, PointUtf16, ReplicaId, ToOffset, Unclipped}; use unindent::Unindent as _; use util::{ - TryFutureExt as _, assert_set_eq, maybe, path, + RandomCharIter, TryFutureExt as _, assert_set_eq, maybe, path, paths::{PathMatcher, PathStyle}, rel_path::{RelPath, rel_path}, test::{TempTree, marked_text_offsets}, @@ -9671,10 +9670,13 @@ async fn test_staged_diff_for_buffer(cx: &mut gpui::TestAppContext) { .unwrap(); cx.run_until_parked(); unstaged_diff.read_with(cx, |diff, cx| { - assert_eq!(diff.base_text(cx).language().cloned(), None); + assert_eq!( + diff.base_text(cx).language().cloned(), + Some(language.clone()) + ); }); - let staged_diff = project + let (staged_diff, index_text_buffer) = project .update(cx, |project, cx| { project.open_staged_diff(buffer.clone(), cx) }) @@ -9682,6 +9684,14 @@ async fn test_staged_diff_for_buffer(cx: &mut gpui::TestAppContext) { .unwrap(); cx.run_until_parked(); + index_text_buffer.read_with(cx, |buffer, cx| { + let file = buffer.file().unwrap(); + assert_eq!(file.file_name(cx), "main.rs"); + assert_eq!( + file.disk_state(), + DiskState::Historic { was_deleted: false } + ); + }); unstaged_diff.read_with(cx, |diff, cx| { assert_eq!( diff.base_text(cx).language().cloned(), @@ -9870,7 +9880,7 @@ async fn test_staged_diff_without_unstaged_diff(cx: &mut gpui::TestAppContext) { .await .unwrap(); - let staged_diff = project + let (staged_diff, _) = project .update(cx, |project, cx| { project.open_staged_diff(buffer.clone(), cx) }) @@ -10157,7 +10167,6 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) { }) .await .unwrap(); - let mut diff_events = cx.events(&uncommitted_diff); // The hunks are initially unstaged. uncommitted_diff.read_with(cx, |diff, cx| { @@ -10189,15 +10198,14 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) { }); // Stage a hunk. It appears as optimistically staged. - uncommitted_diff.update(cx, |diff, cx| { - let range = - snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_before(Point::new(2, 0)); - let hunks = diff - .snapshot(cx) - .hunks_intersecting_range(range, &snapshot) - .collect::>(); - diff.stage_or_unstage_hunks(true, &hunks, &snapshot, true, cx); - + let range = snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_before(Point::new(2, 0)); + project + .update(cx, |project, cx| { + let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); + project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx) + }) + .unwrap(); + uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, @@ -10225,28 +10233,9 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) { ); }); - // The diff emits a change event for the range of the staged hunk. - assert!(matches!( - diff_events.next().await.unwrap(), - BufferDiffEvent::HunksStagedOrUnstaged(_) - )); - let event = diff_events.next().await.unwrap(); - if let BufferDiffEvent::DiffChanged(DiffChanged { - changed_range: Some(changed_range), - base_text_changed_range: _, - extended_range: _, - base_text_changed: _, - }) = event - { - let changed_range = changed_range.to_point(&snapshot); - assert_eq!(changed_range, Point::new(1, 0)..Point::new(2, 0)); - } else { - panic!("Unexpected event {event:?}"); - } - // When the write to the index completes, it appears as staged. cx.run_until_parked(); - uncommitted_diff.update(cx, |diff, cx| { + uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, @@ -10274,21 +10263,6 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) { ); }); - // The diff emits a change event for the changed index text. - let event = diff_events.next().await.unwrap(); - if let BufferDiffEvent::DiffChanged(DiffChanged { - changed_range: Some(changed_range), - base_text_changed_range: _, - extended_range: _, - base_text_changed: _, - }) = event - { - let changed_range = changed_range.to_point(&snapshot); - assert_eq!(changed_range, Point::new(1, 0)..Point::new(2, 0)); - } else { - panic!("Unexpected event {event:?}"); - } - // Simulate a problem writing to the git index. fs.set_error_message_for_index_write( "/dir/.git".as_ref(), @@ -10296,15 +10270,14 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) { ); // Stage another hunk. - uncommitted_diff.update(cx, |diff, cx| { - let range = - snapshot.anchor_before(Point::new(3, 0))..snapshot.anchor_before(Point::new(4, 0)); - let hunks = diff - .snapshot(cx) - .hunks_intersecting_range(range, &snapshot) - .collect::>(); - diff.stage_or_unstage_hunks(true, &hunks, &snapshot, true, cx); - + let range = snapshot.anchor_before(Point::new(3, 0))..snapshot.anchor_before(Point::new(4, 0)); + project + .update(cx, |project, cx| { + let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); + project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx) + }) + .unwrap(); + uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, @@ -10331,27 +10304,10 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) { ], ); }); - assert!(matches!( - diff_events.next().await.unwrap(), - BufferDiffEvent::HunksStagedOrUnstaged(_) - )); - let event = diff_events.next().await.unwrap(); - if let BufferDiffEvent::DiffChanged(DiffChanged { - changed_range: Some(changed_range), - base_text_changed_range: _, - extended_range: _, - base_text_changed: _, - }) = event - { - let changed_range = changed_range.to_point(&snapshot); - assert_eq!(changed_range, Point::new(3, 0)..Point::new(4, 0)); - } else { - panic!("Unexpected event {event:?}"); - } // When the write fails, the hunk returns to being unstaged. cx.run_until_parked(); - uncommitted_diff.update(cx, |diff, cx| { + uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, @@ -10379,32 +10335,29 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) { ); }); - let event = diff_events.next().await.unwrap(); - if let BufferDiffEvent::DiffChanged(DiffChanged { - changed_range: Some(changed_range), - base_text_changed_range: _, - extended_range: _, - base_text_changed: _, - }) = event - { - let changed_range = changed_range.to_point(&snapshot); - assert_eq!(changed_range, Point::new(0, 0)..Point::new(5, 0)); - } else { - panic!("Unexpected event {event:?}"); - } - // Allow writing to the git index to succeed again. fs.set_error_message_for_index_write("/dir/.git".as_ref(), None); // Stage two hunks with separate operations. - uncommitted_diff.update(cx, |diff, cx| { + let (range0, range2) = uncommitted_diff.read_with(cx, |diff, cx| { let hunks = diff.snapshot(cx).hunks(&snapshot).collect::>(); - diff.stage_or_unstage_hunks(true, &hunks[0..1], &snapshot, true, cx); - diff.stage_or_unstage_hunks(true, &hunks[2..3], &snapshot, true, cx); + (hunks[0].buffer_range.clone(), hunks[2].buffer_range.clone()) }); + project + .update(cx, |project, cx| { + let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); + project.stage_hunks(buffer.clone(), unstaged_diff, vec![range0], cx) + }) + .unwrap(); + project + .update(cx, |project, cx| { + let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); + project.stage_hunks(buffer.clone(), unstaged_diff, vec![range2], cx) + }) + .unwrap(); // Both staged hunks appear as pending. - uncommitted_diff.update(cx, |diff, cx| { + uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, @@ -10434,7 +10387,7 @@ async fn test_staging_hunks(cx: &mut gpui::TestAppContext) { // Both staging operations take effect. cx.run_until_parked(); - uncommitted_diff.update(cx, |diff, cx| { + uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, @@ -10631,9 +10584,20 @@ async fn test_staging_hunks_with_delayed_fs_event(cx: &mut gpui::TestAppContext) fs.pause_events(); // Stage the first hunk. - uncommitted_diff.update(cx, |diff, cx| { - let hunk = diff.snapshot(cx).hunks(&snapshot).next().unwrap(); - diff.stage_or_unstage_hunks(true, &[hunk], &snapshot, true, cx); + let range = uncommitted_diff.read_with(cx, |diff, cx| { + diff.snapshot(cx) + .hunks(&snapshot) + .next() + .unwrap() + .buffer_range + }); + project + .update(cx, |project, cx| { + let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); + project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx) + }) + .unwrap(); + uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, @@ -10663,9 +10627,20 @@ async fn test_staging_hunks_with_delayed_fs_event(cx: &mut gpui::TestAppContext) // Stage the second hunk *before* receiving the FS event for the first hunk. cx.run_until_parked(); - uncommitted_diff.update(cx, |diff, cx| { - let hunk = diff.snapshot(cx).hunks(&snapshot).nth(1).unwrap(); - diff.stage_or_unstage_hunks(true, &[hunk], &snapshot, true, cx); + let range = uncommitted_diff.read_with(cx, |diff, cx| { + diff.snapshot(cx) + .hunks(&snapshot) + .nth(1) + .unwrap() + .buffer_range + }); + project + .update(cx, |project, cx| { + let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); + project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx) + }) + .unwrap(); + uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, @@ -10698,10 +10673,19 @@ async fn test_staging_hunks_with_delayed_fs_event(cx: &mut gpui::TestAppContext) cx.run_until_parked(); // Stage the third hunk before receiving the second FS event. - uncommitted_diff.update(cx, |diff, cx| { - let hunk = diff.snapshot(cx).hunks(&snapshot).nth(2).unwrap(); - diff.stage_or_unstage_hunks(true, &[hunk], &snapshot, true, cx); + let range = uncommitted_diff.read_with(cx, |diff, cx| { + diff.snapshot(cx) + .hunks(&snapshot) + .nth(2) + .unwrap() + .buffer_range }); + project + .update(cx, |project, cx| { + let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); + project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx) + }) + .unwrap(); // Wait for all remaining IO. cx.run_until_parked(); @@ -10709,7 +10693,7 @@ async fn test_staging_hunks_with_delayed_fs_event(cx: &mut gpui::TestAppContext) // Now all hunks are staged. cx.run_until_parked(); - uncommitted_diff.update(cx, |diff, cx| { + uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, @@ -10733,6 +10717,167 @@ async fn test_staging_hunks_with_delayed_fs_event(cx: &mut gpui::TestAppContext) }); } +#[gpui::test] +async fn test_restaging_hunk_after_optimistic_unstage(cx: &mut gpui::TestAppContext) { + use DiffHunkSecondaryStatus::*; + init_test(cx); + + let committed_contents = r#" + one + two + three + "# + .unindent(); + let file_contents = r#" + one + TWO + three + "# + .unindent(); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/dir"), + json!({ + ".git": {}, + "file.txt": file_contents.clone() + }), + ) + .await; + + // The hunk starts out staged: the index already matches the worktree. + fs.set_head_for_repo( + path!("/dir/.git").as_ref(), + &[("file.txt", committed_contents.clone())], + "deadbeef", + ); + fs.set_index_for_repo( + path!("/dir/.git").as_ref(), + &[("file.txt", file_contents.clone())], + ); + let repo = fs + .open_repo(path!("/dir/.git").as_ref(), Some("git".as_ref())) + .unwrap(); + + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/file.txt"), cx) + }) + .await + .unwrap(); + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + let uncommitted_diff = project + .update(cx, |project, cx| { + project.open_uncommitted_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + + let hunk_range = uncommitted_diff.read_with(cx, |diff, cx| { + assert_hunks( + diff.snapshot(cx).hunks(&snapshot), + &snapshot, + &diff.base_text_string(cx).unwrap(), + &[( + 1..2, + "two\n", + "TWO\n", + DiffHunkStatus::modified(NoSecondaryHunk), + )], + ); + diff.snapshot(cx) + .hunks(&snapshot) + .next() + .unwrap() + .buffer_range + }); + + // Hold back FS events so that the index writes below stay un-reconciled: + // the second operation must run while the first one's optimistic state is + // still pending. + fs.pause_events(); + + // Unstage the hunk. It appears as optimistically unstaged. + project + .update(cx, |project, cx| { + project.unstage_uncommitted_hunks( + buffer.clone(), + uncommitted_diff.clone(), + vec![hunk_range.clone()], + cx, + ) + }) + .unwrap(); + uncommitted_diff.read_with(cx, |diff, cx| { + assert_hunks( + diff.snapshot(cx).hunks(&snapshot), + &snapshot, + &diff.base_text_string(cx).unwrap(), + &[( + 1..2, + "two\n", + "TWO\n", + DiffHunkStatus::modified(SecondaryHunkAdditionPending), + )], + ); + }); + + // Re-stage the same hunk before the unstage write has been reconciled. + project + .update(cx, |project, cx| { + let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); + project.stage_hunks(buffer.clone(), unstaged_diff, vec![hunk_range.clone()], cx) + }) + .unwrap(); + uncommitted_diff.read_with(cx, |diff, cx| { + assert_hunks( + diff.snapshot(cx).hunks(&snapshot), + &snapshot, + &diff.base_text_string(cx).unwrap(), + &[( + 1..2, + "two\n", + "TWO\n", + DiffHunkStatus::modified(SecondaryHunkRemovalPending), + )], + ); + }); + + cx.run_until_parked(); + assert_eq!( + repo.load_index_text(RepoPath::from_rel_path(rel_path("file.txt"))) + .await + .unwrap(), + file_contents, + "the re-stage should have overridden the in-flight unstage" + ); + + fs.unpause_events_and_flush(); + cx.run_until_parked(); + + // Once everything settles, the hunk is staged again. + uncommitted_diff.read_with(cx, |diff, cx| { + assert_hunks( + diff.snapshot(cx).hunks(&snapshot), + &snapshot, + &diff.base_text_string(cx).unwrap(), + &[( + 1..2, + "two\n", + "TWO\n", + DiffHunkStatus::modified(NoSecondaryHunk), + )], + ); + }); + assert_eq!( + repo.load_index_text(RepoPath::from_rel_path(rel_path("file.txt"))) + .await + .unwrap(), + file_contents + ); +} + #[gpui::test(iterations = 25)] async fn test_staging_random_hunks( mut rng: StdRng, @@ -10746,11 +10891,12 @@ async fn test_staging_random_hunks( use DiffHunkSecondaryStatus::*; init_test(cx); - let committed_text = (0..30).map(|i| format!("line {i}\n")).collect::(); + let committed_text = (0..40).map(|i| format!("line {i}\n")).collect::(); let index_text = committed_text.clone(); - let buffer_text = (0..30) - .map(|i| match i % 5 { - 0 => format!("line {i} (modified)\n"), + let buffer_text = (0..40) + .map(|i| match i { + 35 => format!("line {i}\ninserted after {i}\n"), + _ if i < 30 && i % 5 == 0 => format!("line {i} (modified)\n"), _ => format!("line {i}\n"), }) .collect::(); @@ -10795,24 +10941,75 @@ async fn test_staging_random_hunks( let mut hunks = uncommitted_diff.update(cx, |diff, cx| { diff.snapshot(cx).hunks(&snapshot).collect::>() }); - assert_eq!(hunks.len(), 6); + assert_eq!(hunks.len(), 7); + let insertion_hunk = hunks + .iter() + .find(|hunk| hunk.diff_base_byte_range.is_empty()) + .unwrap() + .clone(); + fs.pause_events(); + project + .update(cx, |project, cx| { + let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); + project.stage_hunks( + buffer.clone(), + unstaged_diff, + vec![insertion_hunk.buffer_range.clone()], + cx, + ) + }) + .unwrap(); + project + .update(cx, |project, cx| { + project.unstage_uncommitted_hunks( + buffer.clone(), + uncommitted_diff.clone(), + vec![insertion_hunk.buffer_range], + cx, + ) + }) + .unwrap(); + cx.executor().run_until_parked(); + assert_eq!( + repo.load_index_text(RepoPath::from_rel_path(rel_path("file.txt"))) + .await + .unwrap(), + index_text + ); + fs.unpause_events_and_flush(); + cx.run_until_parked(); + hunks = uncommitted_diff.update(cx, |diff, cx| { + diff.snapshot(cx).hunks(&snapshot).collect::>() + }); + assert_eq!(hunks.len(), 7); for _i in 0..operations { let hunk_ix = rng.random_range(0..hunks.len()); let hunk = &mut hunks[hunk_ix]; let row = hunk.range.start.row; + let range = hunk.buffer_range.clone(); if hunk.status().has_secondary_hunk() { log::info!("staging hunk at {row}"); - uncommitted_diff.update(cx, |diff, cx| { - diff.stage_or_unstage_hunks(true, std::slice::from_ref(hunk), &snapshot, true, cx); - }); + project + .update(cx, |project, cx| { + let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); + project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx) + }) + .unwrap(); hunk.secondary_status = SecondaryHunkRemovalPending; } else { log::info!("unstaging hunk at {row}"); - uncommitted_diff.update(cx, |diff, cx| { - diff.stage_or_unstage_hunks(false, std::slice::from_ref(hunk), &snapshot, true, cx); - }); + project + .update(cx, |project, cx| { + project.unstage_uncommitted_hunks( + buffer.clone(), + uncommitted_diff.clone(), + vec![range], + cx, + ) + }) + .unwrap(); hunk.secondary_status = SecondaryHunkAdditionPending; } @@ -10853,6 +11050,508 @@ async fn test_staging_random_hunks( }); } +#[gpui::test(iterations = 25)] +async fn test_staging_random_hunks_with_edits( + mut rng: StdRng, + _executor: BackgroundExecutor, + cx: &mut gpui::TestAppContext, +) { + let operations = env::var("OPERATIONS") + .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) + .unwrap_or(30); + + init_test(cx); + + fn disk_index_text(fs: &FakeFs) -> String { + fs.with_git_state(path!("/dir/.git").as_ref(), false, |state| { + state + .index_contents + .get(&repo_path("file.txt")) + .cloned() + .expect("file is always present in the index") + }) + .unwrap() + } + + fn random_row_range( + snapshot: &text::BufferSnapshot, + rng: &mut StdRng, + ) -> (Range, Range) { + let max_row = snapshot.max_point().row; + let start_row = rng.random_range(0..=max_row); + let end_row = rng.random_range(start_row..=max_row.min(start_row + 5)); + let start = Point::new(start_row, 0); + let end = Point::new(end_row, snapshot.line_len(end_row)); + ( + snapshot.anchor_before(start)..snapshot.anchor_after(end), + start_row..end_row, + ) + } + + #[allow(clippy::type_complexity)] + fn capture_diff_state( + diff: &Entity, + cx: &mut gpui::TestAppContext, + ) -> ( + String, + Option, + Vec<(Range, Range, DiffHunkSecondaryStatus)>, + ) { + diff.read_with(cx, |diff, cx| { + let snapshot = diff.snapshot(cx); + let buffer_snapshot = snapshot.buffer_snapshot().clone(); + let hunks = snapshot + .hunks(&buffer_snapshot) + .map(|hunk| { + ( + hunk.range.clone(), + hunk.diff_base_byte_range.clone(), + hunk.secondary_status, + ) + }) + .collect(); + (buffer_snapshot.text(), snapshot.base_text_string(), hunks) + }) + } + + fn assert_settled(diff: &Entity, name: &str, cx: &mut gpui::TestAppContext) { + diff.read_with(cx, |diff, cx| { + let snapshot = diff.snapshot(cx); + let buffer_snapshot = snapshot.buffer_snapshot().clone(); + let cooked = snapshot + .hunks(&buffer_snapshot) + .map(|hunk| (hunk.range.clone(), hunk.secondary_status)) + .collect::>(); + for (range, status) in &cooked { + assert!( + !matches!( + status, + DiffHunkSecondaryStatus::SecondaryHunkAdditionPending + | DiffHunkSecondaryStatus::SecondaryHunkRemovalPending + ), + "{name} hunk at {range:?} still has pending status {status:?} after settling" + ); + } + let full_range = Anchor::min_max_range_for_buffer(buffer_snapshot.remote_id()); + let raw_ranges = snapshot + .raw_hunks_intersecting_range(full_range, &buffer_snapshot) + .map(|hunk| hunk.range) + .collect::>(); + let cooked_ranges = cooked + .into_iter() + .map(|(range, _)| range) + .collect::>(); + assert_eq!( + raw_ranges, cooked_ranges, + "{name} still has suppressed hunks after settling" + ); + }) + } + + let mut committed_text = (0..40) + .map(|i| match i { + _ if i % 5 == 0 => format!("line {i} αβ🍐\n"), + _ => format!("line {i}\n"), + }) + .collect::(); + let index_text = (0..40) + .map(|i| match i { + 10 => "line 10 (modified ✅)\n".to_string(), + 20 => "line 20 (staged-only ⭐)\n".to_string(), + _ if i % 5 == 0 => format!("line {i} αβ🍐\n"), + _ => format!("line {i}\n"), + }) + .collect::(); + let buffer_text = (0..40) + .map(|i| match i { + 35 => format!("line {i} αβ🍐\ninserted after {i}\n"), + _ if i < 30 && i % 5 == 0 => format!("line {i} (modified ✅)\n"), + _ if i % 5 == 0 => format!("line {i} αβ🍐\n"), + _ => format!("line {i}\n"), + }) + .collect::(); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/dir"), + json!({ + ".git": {}, + "file.txt": buffer_text.clone() + }), + ) + .await; + fs.set_head_for_repo( + path!("/dir/.git").as_ref(), + &[("file.txt", committed_text.clone())], + "deadbeef", + ); + fs.set_index_for_repo(path!("/dir/.git").as_ref(), &[("file.txt", index_text)]); + let repo = fs + .open_repo(path!("/dir/.git").as_ref(), Some("git".as_ref())) + .unwrap(); + + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/file.txt"), cx) + }) + .await + .unwrap(); + let unstaged_diff = project + .update(cx, |project, cx| { + project.open_unstaged_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + let (staged_diff, index_buffer) = project + .update(cx, |project, cx| { + project.open_staged_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + let uncommitted_diff = project + .update(cx, |project, cx| { + project.open_uncommitted_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let mut events_paused = false; + let mut commit_count = 0; + for _ in 0..operations { + match rng.random_range(0..100) { + 0..20 => { + let snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); + let (range, rows) = random_row_range(&snapshot, &mut rng); + log::info!("staging rows {rows:?}"); + project + .update(cx, |project, cx| { + project.stage_hunks(buffer.clone(), unstaged_diff.clone(), vec![range], cx) + }) + .unwrap(); + } + 20..35 => { + let snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); + let (range, rows) = random_row_range(&snapshot, &mut rng); + log::info!("unstaging rows {rows:?} via the uncommitted diff"); + project + .update(cx, |project, cx| { + project.unstage_uncommitted_hunks( + buffer.clone(), + uncommitted_diff.clone(), + vec![range], + cx, + ) + }) + .unwrap(); + } + 35..50 => { + let snapshot = index_buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); + let (range, rows) = random_row_range(&snapshot, &mut rng); + log::info!("unstaging index rows {rows:?} via the staged diff"); + project + .update(cx, |project, cx| { + project.unstage_staged_hunks(staged_diff.clone(), vec![range], cx) + }) + .unwrap(); + } + // Line-oriented edits keep the file hunk-rich; uniform random + // splices (`randomly_edit`) delete a quarter of the file on + // average and quickly collapse it into a single hunk. + 50..75 => { + let snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); + let max_row = snapshot.max_point().row; + let row = rng.random_range(0..=max_row); + let row_start = snapshot.point_to_offset(Point::new(row, 0)); + let row_end = snapshot.point_to_offset(Point::new(row, snapshot.line_len(row))); + let new_text_len = rng.random_range(0..8); + let new_text = RandomCharIter::new(&mut rng) + .take(new_text_len) + .collect::(); + let (range, new_text) = match rng.random_range(0..10) { + 0..3 => (row_start..row_start, format!("{new_text}\n")), + 3..5 if max_row > 0 => { + let end = if row < max_row { + snapshot.point_to_offset(Point::new(row + 1, 0)) + } else { + snapshot.len() + }; + (row_start..end, String::new()) + } + 5..8 => (row_start..row_end, new_text), + _ => { + let start = snapshot + .clip_offset(rng.random_range(0..=snapshot.len()), text::Bias::Left); + let end = snapshot.clip_offset( + (start + rng.random_range(0..10)).min(snapshot.len()), + text::Bias::Right, + ); + (start..end, new_text) + } + }; + log::info!("editing buffer: {range:?} -> {new_text:?}"); + buffer.update(cx, |buffer, cx| buffer.edit([(range, new_text)], None, cx)); + } + // Commits and whole-file round-trip checkpoints. The round trips + // settle first so the resulting index bytes are exactly + // predictable, which catches wrong-content writes that the + // settled-consistency checks can't see (a corrupted write becomes + // the on-disk truth that everything else then agrees with). + 75..82 => { + let buffer_full_range = Anchor::min_max_range_for_buffer( + buffer.read_with(cx, |buffer, _| buffer.remote_id()), + ); + let index_full_range = Anchor::min_max_range_for_buffer( + index_buffer.read_with(cx, |buffer, _| buffer.remote_id()), + ); + match rng.random_range(0..4) { + // Commit: HEAD becomes whatever the index currently + // contains on disk, which may lag in-flight optimistic + // writes, just like running `git commit` in a terminal. + 0 => { + let new_head = disk_index_text(&fs); + commit_count += 1; + log::info!("committing (commit {commit_count})"); + fs.set_head_for_repo( + path!("/dir/.git").as_ref(), + &[("file.txt", new_head.clone())], + format!("sha-{commit_count}"), + ); + committed_text = new_head; + } + // Stage everything and commit it, like `git add -A && + // git commit`. + 1 => { + log::info!("checkpoint: stage-all, then commit"); + if events_paused { + fs.unpause_events_and_flush(); + events_paused = false; + } + cx.run_until_parked(); + project + .update(cx, |project, cx| { + project.stage_hunks( + buffer.clone(), + unstaged_diff.clone(), + vec![buffer_full_range], + cx, + ) + }) + .unwrap(); + cx.run_until_parked(); + let disk = disk_index_text(&fs); + let buffer_text = buffer.read_with(cx, |buffer, _| buffer.text()); + assert_eq!( + disk, buffer_text, + "after staging everything, the index must equal the worktree" + ); + commit_count += 1; + log::info!("committing (commit {commit_count})"); + fs.set_head_for_repo( + path!("/dir/.git").as_ref(), + &[("file.txt", disk.clone())], + format!("sha-{commit_count}"), + ); + committed_text = disk; + } + // Unstage everything via the staged diff, which by + // definition covers every index-vs-HEAD difference. + 2 => { + log::info!("checkpoint: unstage-all"); + if events_paused { + fs.unpause_events_and_flush(); + events_paused = false; + } + cx.run_until_parked(); + project + .update(cx, |project, cx| { + project.unstage_staged_hunks( + staged_diff.clone(), + vec![index_full_range], + cx, + ) + }) + .unwrap(); + cx.run_until_parked(); + let disk = disk_index_text(&fs); + assert_eq!( + disk, committed_text, + "after unstaging everything, the index must equal HEAD" + ); + } + // Unstage everything and immediately re-stage everything + // with no settling in between: the re-stage must supersede + // the in-flight unstage (the footprint mechanism), leaving + // the index equal to the worktree. + _ => { + log::info!("checkpoint: unstage-all then stage-all"); + if events_paused { + fs.unpause_events_and_flush(); + events_paused = false; + } + cx.run_until_parked(); + project + .update(cx, |project, cx| { + project.unstage_staged_hunks( + staged_diff.clone(), + vec![index_full_range], + cx, + ) + }) + .unwrap(); + project + .update(cx, |project, cx| { + project.stage_hunks( + buffer.clone(), + unstaged_diff.clone(), + vec![buffer_full_range], + cx, + ) + }) + .unwrap(); + cx.run_until_parked(); + let disk = disk_index_text(&fs); + let buffer_text = buffer.read_with(cx, |buffer, _| buffer.text()); + assert_eq!( + disk, buffer_text, + "re-staging everything must supersede the in-flight unstage" + ); + } + } + } + 82..90 => { + if events_paused { + log::info!("unpausing fs events"); + fs.unpause_events_and_flush(); + events_paused = false; + } else { + log::info!("pausing fs events"); + fs.pause_events(); + events_paused = true; + } + } + _ => { + log::info!("settling"); + if events_paused { + fs.unpause_events_and_flush(); + events_paused = false; + } + cx.run_until_parked(); + } + } + + for _ in 0..rng.random_range(0..5) { + cx.executor().simulate_random_delay().await; + } + } + + if events_paused { + fs.unpause_events_and_flush(); + } + cx.run_until_parked(); + + assert_settled(&unstaged_diff, "unstaged diff", cx); + assert_settled(&staged_diff, "staged diff", cx); + assert_settled(&uncommitted_diff, "uncommitted diff", cx); + + let old_unstaged_state = capture_diff_state(&unstaged_diff, cx); + let old_staged_state = capture_diff_state(&staged_diff, cx); + let old_uncommitted_state = capture_diff_state(&uncommitted_diff, cx); + + let disk_index_text = repo + .load_index_text(RepoPath::from_rel_path(rel_path("file.txt"))) + .await + .unwrap(); + assert_eq!( + old_unstaged_state.1.as_deref(), + Some(disk_index_text.as_str()), + "unstaged diff base text differs from the index on disk" + ); + assert_eq!( + old_staged_state.0, disk_index_text, + "staged diff buffer differs from the index on disk" + ); + assert_eq!( + old_staged_state.1.as_deref(), + Some(committed_text.as_str()), + "staged diff base text differs from HEAD" + ); + assert_eq!( + old_uncommitted_state.1.as_deref(), + Some(committed_text.as_str()), + "uncommitted diff base text differs from HEAD" + ); + + // Differential check: a from-scratch computation over (HEAD, index, + // worktree) must agree with the incrementally-maintained state that just + // settled. + let old_entity_ids = [ + unstaged_diff.entity_id(), + staged_diff.entity_id(), + uncommitted_diff.entity_id(), + ]; + drop(unstaged_diff); + drop(staged_diff); + drop(uncommitted_diff); + // An empty update flushes effects so that the dropped entities are + // actually released before we reopen. + cx.update(|_| {}); + cx.run_until_parked(); + + let unstaged_diff = project + .update(cx, |project, cx| { + project.open_unstaged_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + let (staged_diff, _) = project + .update(cx, |project, cx| { + project.open_staged_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + let uncommitted_diff = project + .update(cx, |project, cx| { + project.open_uncommitted_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + assert_ne!( + unstaged_diff.entity_id(), + old_entity_ids[0], + "the unstaged diff was not actually closed" + ); + assert_ne!( + staged_diff.entity_id(), + old_entity_ids[1], + "the staged diff was not actually closed" + ); + assert_ne!( + uncommitted_diff.entity_id(), + old_entity_ids[2], + "the uncommitted diff was not actually closed" + ); + + assert_eq!( + capture_diff_state(&unstaged_diff, cx), + old_unstaged_state, + "reopened unstaged diff differs from the settled one" + ); + assert_eq!( + capture_diff_state(&staged_diff, cx), + old_staged_state, + "reopened staged diff differs from the settled one" + ); + assert_eq!( + capture_diff_state(&uncommitted_diff, cx), + old_uncommitted_state, + "reopened uncommitted diff differs from the settled one" + ); +} + #[gpui::test] async fn test_single_file_diffs(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -10975,10 +11674,18 @@ async fn test_staging_hunk_preserve_executable_permission(cx: &mut gpui::TestApp .await .unwrap(); - uncommitted_diff.update(cx, |diff, cx| { - let hunks = diff.snapshot(cx).hunks(&snapshot).collect::>(); - diff.stage_or_unstage_hunks(true, &hunks, &snapshot, true, cx); + let ranges = uncommitted_diff.read_with(cx, |diff, cx| { + diff.snapshot(cx) + .hunks(&snapshot) + .map(|hunk| hunk.buffer_range) + .collect::>() }); + project + .update(cx, |project, cx| { + let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); + project.stage_hunks(buffer.clone(), unstaged_diff, ranges, cx) + }) + .unwrap(); cx.run_until_parked(); diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index c4c838042c0..8d589f947cd 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -25,6 +25,11 @@ message UpdateDiffBases { // and the contents of the index and HEAD differ for this path, // where None means the path doesn't exist in that state of the repo. INDEX_AND_HEAD = 3; + // The contents of the index and HEAD are unchanged. Sent so that + // remote clients still recalculate their diffs in response to git + // events, just as the host does, even when an index write ends up + // changing nothing (e.g. writing content identical to what's there). + UNCHANGED = 4; } optional string staged_text = 3; diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index d0c0a5385ff..2c51c97a0eb 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -101,7 +101,6 @@ impl EventEmitter for BufferSearchBar {} impl Render for BufferSearchBar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let focus_handle = self.focus_handle(cx); - let has_splittable_editor = self.splittable_editor.is_some(); let split_buttons = self .splittable_editor @@ -539,7 +538,9 @@ impl ToolbarItemView for BufferSearchBar { .and_then(|entity| entity.downcast::().ok()) { self._splittable_editor_subscription = - Some(cx.observe(&splittable_editor, |_, _, cx| cx.notify())); + Some(cx.observe(&splittable_editor, |_, _, cx| { + cx.notify(); + })); self.splittable_editor = Some(splittable_editor.downgrade()); } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 69fc69d5469..2ecd422be6e 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -31,10 +31,13 @@ use feature_flags::{FeatureFlagAppExt as _, PanicFeatureFlag}; use fs::Fs; use futures::FutureExt as _; use futures::{StreamExt, channel::mpsc, select_biased}; +use git_ui::branch_diff::BranchDiffToolbar; use git_ui::commit_view::CommitViewToolbar; use git_ui::git_panel::GitPanel; -use git_ui::project_diff::{BranchDiffToolbar, ProjectDiffToolbar}; +use git_ui::project_diff::ProjectDiffToolbar; use git_ui::solo_diff_view::{SoloDiffGitToolbar, SoloDiffStyleToolbar}; +use git_ui::staged_diff::StagedDiffToolbar; +use git_ui::unstaged_diff::UnstagedDiffToolbar; use gpui::{ Action, App, AppContext as _, AsyncWindowContext, ClipboardItem, Context, DismissEvent, Element, Entity, FocusHandle, Focusable, Image, ImageFormat, KeyBinding, ParentElement, @@ -1413,6 +1416,10 @@ fn initialize_pane( toolbar.add_item(highlights_tree_item, window, cx); let project_diff_toolbar = cx.new(|cx| ProjectDiffToolbar::new(workspace, cx)); toolbar.add_item(project_diff_toolbar, window, cx); + let staged_diff_toolbar = cx.new(|cx| StagedDiffToolbar::new(workspace, cx)); + toolbar.add_item(staged_diff_toolbar, window, cx); + let unstaged_diff_toolbar = cx.new(|cx| UnstagedDiffToolbar::new(workspace, cx)); + toolbar.add_item(unstaged_diff_toolbar, window, cx); let branch_diff_toolbar = cx.new(BranchDiffToolbar::new); toolbar.add_item(branch_diff_toolbar, window, cx); let solo_diff_git_toolbar = cx.new(SoloDiffGitToolbar::new); diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 7eb78ca0ecc..c70e8d3273c 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -352,6 +352,12 @@ pub mod git { /// Opens the git branch selector. #[action(deprecated_aliases = ["branches::OpenRecent"])] Branch, + /// Shows uncommitted changes across the project. + ViewUncommittedChanges, + /// Shows unstaged changes across the project. + ViewUnstagedChanges, + /// Shows staged changes across the project. + ViewStagedChanges, /// Opens the git stash selector. ViewStash, /// Opens the git worktree selector. From 28c2e7d1e4dc9c8df965adf7656f9b1d993c9a23 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 7 Jul 2026 03:28:33 -0400 Subject: [PATCH 099/197] Fix tree-sitter-markdown scanner serialize buffer overflow (#60312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zed bundles the markdown grammar's block scanner natively, and its `serialize()` `memcpy`s the open-block stack into tree-sitter's fixed 1024-byte serialization buffer with no bounds check. Markdown with roughly 255+ nested blocks overflows that buffer, and because it sits at the front of `struct TSParser`, the overflow clobbers the adjacent parse-stack pointer and heap. Debug builds of the tree-sitter runtime catch this with an assertion, but release builds like Zed's have no check and silently corrupt parser memory — which is why this surfaced as wild crashes deep in tree-sitter's parse stack rather than clean failures. Still open upstream as tree-sitter-grammars/tree-sitter-markdown#243. This PR points `tree-sitter-md` at a `zed-industries` fork whose `serialize()` refuses to write state that doesn't fit (bounded by the same running counter the header writes advance, so the check can't drift). The scanner then deserializes to a fresh state, and the pathologically nested region surfaces as ordinary tree-sitter `ERROR` nodes — visible, safe degradation for an adversarial input class, deliberately chosen over the two alternatives: truncating the block stack would deserialize into a plausible-but-wrong state and produce silently incorrect trees, and the scanner ABI offers no error channel at all (`serialize()` returns a length into a fixed buffer; there is no way to fail a parse). A nesting-depth cap at block-open time would give fully deterministic semantics, but that's a behavior change across 13 scanner call sites that belongs upstream, not in a hotfix fork. The pinned branch is upstream's `9a23c1a9` (the revision Zed already pinned) plus exactly two commits, for easy review: the guard (zed-industries/tree-sitter-markdown@179422edf8d0ee511bd3875eb8942d16a8f1f277) and regression tests (zed-industries/tree-sitter-markdown@b596e737286780d7bfa9fcddceaeeb754574b352). The deep-nesting test aborts on the unguarded scanner and passes with the guard; a moderate-nesting test pins that inputs fitting the buffer still parse cleanly. The same change is also up as zed-industries/tree-sitter-markdown#1 into the fork's default branch (`split_parser`), so future pin bumps don't lose it; if upstream fixes #243, we can drop the fork entirely on the next bump. Closes FR-115 Release Notes: - Fixed a potential crash when editing Markdown with deeply nested blocks --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5a64dbf6b4f..689b4f65498 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19445,7 +19445,7 @@ checksum = "c4013970217383f67b18aef68f6fb2e8d409bc5755227092d32efb0422ba24b8" [[package]] name = "tree-sitter-md" version = "0.3.2" -source = "git+https://github.com/tree-sitter-grammars/tree-sitter-markdown?rev=9a23c1a96c0513d8fc6520972beedd419a973539#9a23c1a96c0513d8fc6520972beedd419a973539" +source = "git+https://github.com/zed-industries/tree-sitter-markdown?rev=b596e737286780d7bfa9fcddceaeeb754574b352#b596e737286780d7bfa9fcddceaeeb754574b352" dependencies = [ "cc", "tree-sitter-language", diff --git a/Cargo.toml b/Cargo.toml index 2adf179e513..15c7b4401a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -809,7 +809,7 @@ tree-sitter-heex = { git = "https://github.com/zed-industries/tree-sitter-heex", tree-sitter-html = "0.23" tree-sitter-jsdoc = "0.23" tree-sitter-json = "0.24" -tree-sitter-md = { git = "https://github.com/tree-sitter-grammars/tree-sitter-markdown", rev = "9a23c1a96c0513d8fc6520972beedd419a973539" } +tree-sitter-md = { git = "https://github.com/zed-industries/tree-sitter-markdown", rev = "b596e737286780d7bfa9fcddceaeeb754574b352" } # fork of 9a23c1a with serialize() buffer-overflow fix; https://github.com/tree-sitter-grammars/tree-sitter-markdown/issues/243 tree-sitter-python = "0.25" tree-sitter-regex = "0.24" tree-sitter-ruby = "0.23" From d9ada8487bca20be32c7621e296030c9d68362f5 Mon Sep 17 00:00:00 2001 From: Apoorva Verma <76636952+apoorva-01@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:15:54 +0530 Subject: [PATCH 100/197] Fix hard-tab block autoindent skipping unindented lines (#60406) # Objective Fixes #59979 With hard tabs on, expanding a multi-line snippet only re-indents the lines that already start with a tab. Anything with no leading whitespace (closing brackets, mostly) stays at column 0. ## Solution Block autoindent shifts each line by the first line's delta, but only when the line's indent kind matches the target kind. A line with no indentation defaults to `IndentKind::Space`, so under hard tabs it never matches and gets skipped. An empty indent doesn't really have a kind, so I let it adopt the target kind before the check. Lines that actually have space indentation in a tab buffer are still left alone. Normalizing those felt like a bigger question than this bug, happy to look at it separately if wanted. ## Testing New `test_autoindent_block_mode_with_hard_tabs`, same shape as the snippet in the issue. Fails on main (closing brace stays at column 0), passes with the fix. `cargo test -p language` and `-p editor` are green, clippy and fmt too. ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed multi-line snippets leaving unindented lines at column 0 when `hard_tabs` is enabled. --- crates/language/src/buffer.rs | 5 +++++ crates/language/src/buffer_tests.rs | 32 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index d41143d12f5..c37065146f2 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -2163,6 +2163,11 @@ impl Buffer { for row in row_range.skip(1) { indent_sizes.entry(row).or_insert_with(|| { let mut size = snapshot.indent_size_for_line(row); + // A line with no indentation has an arbitrary + // indent kind, so it can adopt the new kind. + if size.len == 0 { + size.kind = new_indent.kind; + } if size.kind == new_indent.kind { match delta.cmp(&0) { Ordering::Greater => size.len += delta as u32, diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index cdcd90314cd..29afbe84b72 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -2180,6 +2180,38 @@ fn test_autoindent_block_mode_without_original_indent_columns(cx: &mut App) { }); } +#[gpui::test] +fn test_autoindent_block_mode_with_hard_tabs(cx: &mut App) { + init_settings(cx, |settings| { + settings.defaults.hard_tabs = Some(true); + }); + + cx.new(|cx| { + let text = "fn a() {\n\tb();\n}"; + let mut buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); + + // Insert a block whose indentation mixes tab-indented lines with + // lines that have no leading whitespace, like a snippet body. + let inserted_text = "if c {\n\td();\n}\n"; + buffer.edit( + [(Point::new(2, 0)..Point::new(2, 0), inserted_text)], + Some(AutoindentMode::Block { + original_indent_columns: Vec::new(), + }), + cx, + ); + + // All of the block's lines are indented, including the ones that + // originally had no indentation. + assert_eq!( + buffer.text(), + "fn a() {\n\tb();\n\tif c {\n\t\td();\n\t}\n}" + ); + + buffer + }); +} + #[gpui::test] fn test_autoindent_block_mode_multiple_adjacent_ranges(cx: &mut App) { init_settings(cx, |_| {}); From 001bda1a46af8a8e7d0abc89aaf67a6a1193426f Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Tue, 7 Jul 2026 13:19:53 +0530 Subject: [PATCH 101/197] project: Fix ref-match rust completions dropping the leading & (#60521) Closes #56973, Follow-up for https://github.com/zed-industries/zed/pull/56976 When a completion's `additionalTextEdits` contained a zero-width insertion touching the edge of the primary edit, the overlap check treated it as overlapping and silently skipped it. This broke rust-analyzer's ref-match completions (`&foo`), which deliver the `&` as a zero-width additional edit at the primary edit's start (#56973). The same root cause previously broke auto-imports at the very start of a file (#26136), which was worked around in #37746 with a special case for edits at position (0, 0). This PR replaces that special case with a general rule: a zero-width additional edit only overlaps the primary edit when it falls strictly inside it. i.e. touching the boundary is fine. This one check handles both the file-start auto-import case and the `&` ref-match case, so the (0, 0) workaround from #37746 is removed. Non-insertion edits still go through the original overlap check from #1871. Added three regression tests: - the file-start auto-import (#26136) - the `&` insertion at the primary edit's start (#56973) - overlapping edits being skipped while non-overlapping ones apply (general skip case) Release Notes: - Fixed rust-analyzer completions like `&some_var` inserting only the variable name and dropping the leading `&` when accepted. --- crates/editor/src/editor_tests.rs | 160 ++++++++++++++++++++++++++++++ crates/project/src/lsp_store.rs | 38 +++---- 2 files changed, 175 insertions(+), 23 deletions(-) diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 181a4d447ba..2fa319300e5 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -22255,6 +22255,166 @@ async fn test_completions_with_additional_edits(cx: &mut TestAppContext) { cx.assert_editor_state("fn main() { let a = Some(2)ˇ; }"); } +async fn check_completion_additional_edits( + initial_state: &str, + keystroke: &str, + completion_item: lsp::CompletionItem, + state_after_primary_edit: &str, + state_after_additional_edits: &str, + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + resolve_provider: Some(true), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state(initial_state); + + let closure_completion_item = completion_item.clone(); + let mut request = cx.set_request_handler::(move |_, _, _| { + let task_completion_item = closure_completion_item.clone(); + async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + task_completion_item, + ]))) + } + }); + + cx.simulate_keystroke(keystroke); + request.next().await; + + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + let apply_additional_edits = cx.update_editor(|editor, window, cx| { + editor + .confirm_completion(&ConfirmCompletion::default(), window, cx) + .unwrap() + }); + cx.assert_editor_state(state_after_primary_edit); + + cx.set_request_handler::(move |_, _, _| { + let task_completion_item = completion_item.clone(); + async move { Ok(task_completion_item) } + }) + .next() + .await + .unwrap(); + apply_additional_edits.await.unwrap(); + cx.assert_editor_state(state_after_additional_edits); +} + +// rust-analyzer's ref-match completions (`&some_str`) deliver the `&` as a +// zero-width additionalTextEdit at the primary edit's start. +// Ref: https://github.com/zed-industries/zed/issues/56973 +#[gpui::test] +async fn test_completions_with_zero_width_additional_edit_at_primary_edit_start( + cx: &mut TestAppContext, +) { + check_completion_additional_edits( + // The completion must not start at (0, 0), so that this case is + // distinct from the file-start auto-import test below. + "bar(fˇ)", + "o", + lsp::CompletionItem { + label: "&foo".to_string(), + filter_text: Some("foo".to_string()), + // Replaces the typed `fo` with `foo`; the committed range is 4..7. + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 4), lsp::Position::new(0, 6)), + new_text: "foo".to_string(), + })), + // Zero-width insertion exactly at the committed range's start. + additional_text_edits: Some(vec![lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 4), lsp::Position::new(0, 4)), + new_text: "&".to_string(), + }]), + ..Default::default() + }, + "bar(fooˇ)", + "bar(&fooˇ)", + cx, + ) + .await; +} + +// Additional edits which overlap the primary completion edit must be skipped +// while non-overlapping edits from the same completion are still applied. +// Ref: https://github.com/zed-industries/zed/pull/1871 +#[gpui::test] +async fn test_completions_skip_additional_edits_overlapping_primary_edit(cx: &mut TestAppContext) { + check_completion_additional_edits( + "fˇ\nbar", + "o", + lsp::CompletionItem { + label: "foo".to_string(), + filter_text: Some("foo".to_string()), + // Replaces the typed `fo` with `foo`; the committed range is 0..3. + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 2)), + new_text: "foo".to_string(), + })), + additional_text_edits: Some(vec![ + // Skip text strictly inside the committed range. + lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 1), lsp::Position::new(0, 2)), + new_text: "XXX".to_string(), + }, + // Apply text which does not touch the committed range. + lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 3)), + new_text: "baz".to_string(), + }, + ]), + ..Default::default() + }, + "fooˇ\nbar", + "fooˇ\nbaz", + cx, + ) + .await; +} + +// When both the primary completion edit and an additional edit (auto-import) +// start at the very beginning of the file, the additional edit must not be +// treated as overlapping. This payload shape matches what +// typescript-language-server actually sends for auto-imports at file start. +// Ref: https://github.com/zed-industries/zed/issues/26136 +#[gpui::test] +async fn test_completions_with_file_start_auto_import_additional_edit(cx: &mut TestAppContext) { + check_completion_additional_edits( + "ˇ", + "f", + lsp::CompletionItem { + label: "foo".to_string(), + // Replaces the typed `f` at file start; the committed range is 0..3. + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 1)), + new_text: "foo".to_string(), + })), + // Auto-import inserted at the very start of the file. + additional_text_edits: Some(vec![lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)), + new_text: "bar\n".to_string(), + }]), + ..Default::default() + }, + "fooˇ", + "bar\nfooˇ", + cx, + ) + .await; +} + #[gpui::test] async fn test_completions_with_additional_edits_undo(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index e7ac11e773b..666ba714f97 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -7238,27 +7238,19 @@ impl LspStore { buffer.start_transaction(); for (range, text) in edits { - let primary = &completion.replace_range; - - // Special case: if both ranges start at the very beginning of the file (line 0, column 0), - // and the primary completion is just an insertion (empty range), then this is likely - // an auto-import scenario and should not be considered overlapping - // https://github.com/zed-industries/zed/issues/26136 - let is_file_start_auto_import = { - let snapshot = buffer.snapshot(); - let primary_start_point = primary.start.to_point(&snapshot); - let range_start_point = range.start.to_point(&snapshot); - - let result = primary_start_point.row == 0 - && primary_start_point.column == 0 - && range_start_point.row == 0 - && range_start_point.column == 0; - - result - }; - - let has_overlap = if is_file_start_auto_import { - false + // Zero-width additional edits (e.g. auto-imports at file start, or + // rust-analyzer's ref-match `&` insertions) only overlap the primary + // edit when they fall strictly inside it. Touching its boundary is fine. + // + // Ref: https://github.com/zed-industries/zed/issues/26136 + // Ref: https://github.com/zed-industries/zed/issues/56973 + let is_insertion = range.start.cmp(&range.end, buffer).is_eq(); + let has_overlap = if is_insertion { + let insert_offset = range.start.to_offset(buffer); + all_commit_ranges.iter().any(|commit_range| { + commit_range.start.to_offset(buffer) < insert_offset + && insert_offset < commit_range.end.to_offset(buffer) + }) } else { all_commit_ranges.iter().any(|commit_range| { let start_within = @@ -7271,8 +7263,8 @@ impl LspStore { }) }; - //Skip additional edits which overlap with the primary completion edit - //https://github.com/zed-industries/zed/pull/1871 + // Skip additional edits which overlap with the primary completion edit + // https://github.com/zed-industries/zed/pull/1871 if !has_overlap { buffer.edit([(range, text)], None, cx); } From a956add0b655f5b20665cf34641f7c08ede0dafc Mon Sep 17 00:00:00 2001 From: counterfactual5 <108983446+counterfactual5@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:56:02 +0800 Subject: [PATCH 102/197] Fix MCP tools with $ref/$defs being silently rejected (#60165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #60162. MCP servers whose tool `inputSchema` uses `$ref`/`$defs` (e.g., Notion MCP v2.x, and any server using Zod/Pydantic-generated schemas) are silently rejected with: ``` ERROR Schema cannot be made compatible because it contains "$ref" ``` The affected tools are dropped from the agent panel — the user never sees them and there is no user-visible error. ## Root cause `adapt_to_json_schema_subset` rejects any schema containing `$ref` via `UNSUPPORTED_KEYS`: ```rust const UNSUPPORTED_KEYS: [&str; 4] = ["if", "then", "else", "$ref"]; ``` This is hit by every provider that uses `JsonSchemaSubset` format (Google Gemini, xAI Grok, OpenAI-compatible proxies, Vercel AI Gateway, Copilot Chat for Google/xAI vendors, OpenRouter for gemini/grok models). Providers using `JsonSchema` (Anthropic direct, OpenAI direct) don't hit this check — `$ref` is passed through to the API, which may or may not handle it correctly. This is not an edge case. Every modern MCP server using Zod (TypeScript), Pydantic (Python), or JSON Schema with shared definitions generates `$ref`/`$defs` in tool schemas. ## Fix Add a `resolve_refs` step in `adapt_schema_to_format` that dereferences all `$ref` pointers using the document's own `$defs` (or legacy `definitions`) map, making the schema self-contained before format-specific processing. Applied at the entry point so both `JsonSchema` and `JsonSchemaSubset` formats benefit. **Scope note:** previously `JsonSchema` providers (Anthropic direct, OpenAI direct) received the raw `$ref`/`$defs` and were expected to handle it themselves — which most do not. After this change, both paths receive a self-contained schema with refs inlined. This is intentional: it fixes the same root cause for both paths and avoids provider-specific behavior divergence. Supported `$ref` forms: - `#/$defs/` (JSON Schema draft 2019-09+) - `#/definitions/` (draft 4-7 legacy) Edge cases: - **Nested `$ref`** (definition references another definition): resolved recursively. - **Sibling properties alongside `$ref`** (e.g. `{ "$ref": "...", "description": "..." }`, legal under draft 2019-09+): merged onto the resolved definition, with siblings overriding the definition's keys. - **Cyclic references** (A → B → A, or self-referential schemas like a Tree node): replaced with an empty schema `{}` ("any JSON value"). The tool still works, just without type info for that recursive field. - **Unsupported `$ref` forms** (e.g., external URLs): returns an error with a clear message. - **Missing definition target**: returns an error naming the missing ref. ## Testing Added 10 unit tests in `crates/language_model_core/src/tool_schema.rs` that cover the patterns produced by Zod/Pydantic-generated MCP schemas: - `test_refs_are_resolved_via_adapt_schema_to_format` — basic `$ref` → `$defs` resolution - `test_refs_in_defs_are_resolved` — nested `$ref` (definition references another definition) - `test_refs_in_array_items_are_resolved` — `$ref` inside `array.items` - `test_legacy_definitions_prefix_is_supported` — old `#/definitions/` prefix - `test_schema_without_defs_is_unchanged` — schemas with no `$defs` are unaffected - `test_refs_fail_for_unsupported_prefix` — external URL refs error clearly - `test_refs_fail_for_missing_definition` — missing target errors clearly - `test_cyclic_refs_are_replaced_with_empty_schema` — A → B → A cycle replaced with `{}` - `test_self_referential_ref_is_replaced_with_empty_schema` — Tree-like self-ref replaced with `{}` - `test_ref_sibling_properties_are_preserved` — sibling properties alongside `$ref` are merged onto the resolved definition Existing tests (which call `adapt_to_json_schema_subset` and `preprocess_json_schema` directly) are unaffected — the fix is additive at the `adapt_schema_to_format` level. ## Disclosure I used an LLM to help draft the implementation and tests. I reviewed and understand the change — it adds one new function (`resolve_refs`) with two helpers (`parse_ref`, `resolve_refs_recursive`), plus unit tests. Release Notes: - Fixed MCP tools with `$ref`/`$defs` in their `inputSchema` being silently rejected by providers using the JSON Schema Subset format (Google Gemini, xAI Grok, OpenAI-compatible proxies, etc.). Tools from servers like Notion MCP v2.x, and any server using Zod or Pydantic-generated schemas, now work correctly. --------- Co-authored-by: Bennet Bo Fenner Co-authored-by: Bennet Bo Fenner --- Cargo.lock | 1 + crates/language_model_core/Cargo.toml | 3 + crates/language_model_core/src/tool_schema.rs | 396 ++++++++++++++++++ 3 files changed, 400 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 689b4f65498..b1d1e834170 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9792,6 +9792,7 @@ dependencies = [ "http_client", "log", "partial-json-fixer", + "pretty_assertions", "schemars 1.0.4", "serde", "serde_json", diff --git a/crates/language_model_core/Cargo.toml b/crates/language_model_core/Cargo.toml index c254989b4d5..f4a59a3e08d 100644 --- a/crates/language_model_core/Cargo.toml +++ b/crates/language_model_core/Cargo.toml @@ -26,3 +26,6 @@ serde.workspace = true serde_json.workspace = true strum.workspace = true thiserror.workspace = true + +[dev-dependencies] +pretty_assertions.workspace = true diff --git a/crates/language_model_core/src/tool_schema.rs b/crates/language_model_core/src/tool_schema.rs index 13e6e665244..49c5d4bbe75 100644 --- a/crates/language_model_core/src/tool_schema.rs +++ b/crates/language_model_core/src/tool_schema.rs @@ -82,6 +82,8 @@ pub fn adapt_schema_to_format( obj.remove("description"); } + resolve_refs(json)?; + match format { LanguageModelToolSchemaFormat::JsonSchema => preprocess_json_schema(json), LanguageModelToolSchemaFormat::JsonSchemaSubset => adapt_to_json_schema_subset(json), @@ -106,6 +108,98 @@ fn preprocess_json_schema(json: &mut Value) -> Result<()> { Ok(()) } +/// Inlines same-document `$ref`s from `$defs`/`definitions` and removes those. +fn resolve_refs(json: &mut Value) -> Result<()> { + let Some(root_obj) = json.as_object_mut() else { + return Ok(()); + }; + + let defs = root_obj.remove("$defs"); + let legacy_defs = root_obj.remove("definitions"); + if defs.is_none() && legacy_defs.is_none() { + return Ok(()); + } + + resolve_refs_recursive(json, defs.as_ref(), legacy_defs.as_ref(), &mut Vec::new()) +} + +fn resolve_refs_recursive( + value: &mut Value, + defs: Option<&Value>, + legacy_defs: Option<&Value>, + visiting: &mut Vec, +) -> Result<()> { + match value { + Value::Object(obj) => { + if let Some(ref_str) = obj.get("$ref").and_then(|v| v.as_str()) { + // Guard against cycles (A -> B -> A, or self-referential + // schemas like a Tree node whose children are Trees) + if visiting.iter().any(|v| v == ref_str) { + *obj = Map::new(); + return Ok(()); + } + + let (defs_key, name) = parse_ref(ref_str)?; + let defs_for_key = match defs_key { + "$defs" => defs, + "definitions" => legacy_defs, + _ => None, + }; + let Some(def) = defs_for_key.and_then(|defs| defs.get(name)) else { + anyhow::bail!("$ref target not found in {defs_key}: {ref_str}"); + }; + + let ref_owned = ref_str.to_string(); + + // Inline the referenced definition into the current object. + let mut resolved = def.clone(); + if let Value::Object(resolved_obj) = &mut resolved { + for (key, val) in obj.iter() { + if key != "$ref" { + resolved_obj.insert(key.clone(), val.clone()); + } + } + } + *value = resolved; + + visiting.push(ref_owned); + let result = resolve_refs_recursive(value, defs, legacy_defs, visiting); + visiting.pop(); + return result; + } + + let keys: Vec = obj.keys().cloned().collect(); + for key in keys { + if let Some(child) = obj.get_mut(&key) { + resolve_refs_recursive(child, defs, legacy_defs, visiting)?; + } + } + } + Value::Array(arr) => { + for item in arr.iter_mut() { + resolve_refs_recursive(item, defs, legacy_defs, visiting)?; + } + } + _ => {} + } + Ok(()) +} + +/// Parses a same-document `$ref` like `#/$defs/Foo` or `#/definitions/Foo`. +/// Returns `(defs_key, name)` where `defs_key` is the top-level key the +/// definition was looked up under, and `name` is the definition name. +fn parse_ref(ref_str: &str) -> Result<(&'static str, &str)> { + if let Some(name) = ref_str.strip_prefix("#/$defs/") { + return Ok(("$defs", name)); + } + if let Some(name) = ref_str.strip_prefix("#/definitions/") { + return Ok(("definitions", name)); + } + anyhow::bail!( + "Unsupported $ref format (only `#/$defs/` and `#/definitions/` are supported): {ref_str}" + ); +} + fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> { if let Value::Object(obj) = json { const UNSUPPORTED_KEYS: [&str; 4] = ["if", "then", "else", "$ref"]; @@ -305,6 +399,7 @@ fn collapse_nullable_only_any_of(obj: &mut Map) { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; use serde_json::json; fn obj(value: Value) -> Map { @@ -787,6 +882,307 @@ mod tests { assert!(adapt_to_json_schema_subset(&mut json).is_err()); } + #[test] + fn test_refs_are_resolved_via_adapt_schema_to_format() { + let mut json = json!({ + "type": "object", + "properties": { + "parent": { + "$ref": "#/$defs/pageParent" + }, + "title": { + "type": "string", + "description": "Page title" + } + }, + "required": ["parent"], + "$defs": { + "pageParent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Parent type" + } + }, + "required": ["type"] + } + } + }); + + adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset).unwrap(); + + let expected = json!({ + "type": "object", + "properties": { + "parent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Parent type" + } + }, + "required": ["type"] + }, + "title": { + "type": "string", + "description": "Page title" + } + }, + "required": ["parent"], + }); + assert_eq!(json, expected); + } + + #[test] + fn test_refs_fail_for_unsupported_prefix() { + let mut json = json!({ + "type": "object", + "properties": { + "child": { + "$ref": "https://example.com/schema.json#/User" + } + }, + "$defs": { + "User": { "type": "string" } + } + }); + + assert!( + adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset) + .is_err() + ); + } + + #[test] + fn test_refs_fail_for_missing_definition() { + let mut json = json!({ + "type": "object", + "properties": { + "child": { + "$ref": "#/$defs/NonExistent" + } + }, + "$defs": { + "Existing": { "type": "string" } + } + }); + + assert!( + adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset) + .is_err() + ); + } + + #[test] + fn test_refs_in_defs_are_resolved() { + // A definition that itself references another definition. + let mut json = json!({ + "type": "object", + "properties": { + "parent": { + "$ref": "#/$defs/pageParent" + } + }, + "$defs": { + "pageParent": { + "type": "object", + "properties": { + "database_id": { + "$ref": "#/$defs/databaseId" + } + } + }, + "databaseId": { + "type": "string", + "description": "A database ID" + } + } + }); + + adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset).unwrap(); + + // The nested $ref in pageParent -> databaseId should be resolved. + assert_eq!( + json, + json!({ + "type": "object", + "properties": { + "parent": { + "type": "object", + "properties": { + "database_id": { + "type": "string", + "description": "A database ID" + } + } + } + } + }) + ); + } + + #[test] + fn test_refs_resolve_when_both_defs_and_definitions_exist() { + let mut json = json!({ + "type": "object", + "properties": { + "modern": { + "$ref": "#/$defs/Modern" + }, + "legacy": { + "$ref": "#/definitions/Legacy" + } + }, + "$defs": { + "Modern": { + "type": "string" + } + }, + "definitions": { + "Legacy": { + "type": "number" + } + } + }); + + adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset).unwrap(); + + assert_eq!( + json, + json!({ + "type": "object", + "properties": { + "modern": { + "type": "string" + }, + "legacy": { + "type": "number" + } + } + }) + ); + } + + #[test] + fn test_refs_in_array_items_are_resolved() { + let mut json = json!({ + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/itemDef" + } + } + }, + "$defs": { + "itemDef": { + "type": "string", + "description": "An item" + } + } + }); + + adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset).unwrap(); + + assert_eq!( + json, + json!({ + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string", + "description": "An item" + } + } + } + }) + ); + } + + #[test] + fn test_self_referential_ref_is_replaced_with_empty_schema() { + // A common pattern: a Tree node with children of the same type. + let mut json = json!({ + "type": "object", + "properties": { + "root": { "$ref": "#/$defs/Tree" } + }, + "$defs": { + "Tree": { + "type": "object", + "properties": { + "value": { "type": "string" }, + "children": { + "type": "array", + "items": { "$ref": "#/$defs/Tree" } + } + } + } + } + }); + + adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset) + .expect("self-referential $ref should not error"); + + assert_eq!( + json, + json!({ + "type": "object", + "properties": { + "root": { + "type": "object", + "properties": { + "value": { "type": "string" }, + "children": { + "type": "array", + "items": {} + } + } + } + } + }) + ); + } + + #[test] + fn test_ref_sibling_properties_are_preserved() { + // JSON Schema draft 2019-09+ allows sibling properties alongside + // `$ref`. They must be merged into the resolved definition rather than + // discarded, with siblings overriding the definition's keys. + let mut json = json!({ + "type": "object", + "properties": { + "child": { + "$ref": "#/$defs/childDef", + "description": "Local description overrides def" + } + }, + "$defs": { + "childDef": { + "type": "string", + "description": "Def description", + "minLength": 1 + } + } + }); + + adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset).unwrap(); + + assert_eq!( + json["properties"]["child"], + json!({ + "type": "string", + "description": "Local description overrides def", + "minLength": 1 + }) + ); + } + #[test] fn test_preprocess_json_schema_adds_additional_properties() { let mut json = json!({ From fcd0f769521884e71b9b54ab964bd88cf057a276 Mon Sep 17 00:00:00 2001 From: Remco Smits Date: Tue, 7 Jul 2026 10:01:05 +0200 Subject: [PATCH 103/197] git_ui: make git graph columns toggleable (#59850) # Objective TODO ## Solution TODO ## Self-Review Checklist: - [ ] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable ## Showcase https://github.com/user-attachments/assets/ae1e2759-2ae9-4c6f-aba3-b6b557c673bc --- cc @Anthony-Eid Release Notes: - Git graph: Make columns toggleable --------- Co-authored-by: Anthony Co-authored-by: Anthony Eid --- crates/git_ui/src/git_graph.rs | 351 ++++++++++++++---- crates/ui/src/components/context_menu.rs | 16 +- crates/ui/src/components/data_table.rs | 143 ++++--- .../ui/src/components/data_table/table_row.rs | 4 + crates/ui/src/components/data_table/tests.rs | 236 ++++++++++++ .../src/components/redistributable_columns.rs | 297 +++++++++++---- 6 files changed, 877 insertions(+), 170 deletions(-) diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index 9366039969e..b360e1ec578 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -55,8 +55,8 @@ use ui::{ Divider, HeaderResizeInfo, HighlightedLabel, ListItem, ListItemSpacing, RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState, TableRenderContext, TableResizeBehavior, Tooltip, WithScrollbar, bind_redistributable_columns, - prelude::*, render_redistributable_columns_resize_handles, render_table_header, - table_row::TableRow, + prelude::*, redistribute_hidden_fractions, redistribute_hidden_widths, + render_redistributable_columns_resize_handles, render_table_header, table_row::TableRow, }; use util::{ResultExt, debug_panic}; use workspace::{ @@ -73,6 +73,7 @@ const RESIZE_HANDLE_WIDTH: f32 = 8.0; const COPIED_STATE_DURATION: Duration = Duration::from_secs(2); const COMMIT_TAG_LIST_WIDTH_IN_REMS: Rems = rems(10.); const CUSTOM_GIT_COMMANDS_DOCS_SLUG: &str = "tasks#custom-git-commands"; +const TABLE_COLUMN_COUNT: usize = 4; // Extra vertical breathing room added to the UI line height when computing // the git graph's row height, so commit dots and lines have space around them. const ROW_VERTICAL_PADDING: Pixels = px(4.0); @@ -1320,7 +1321,7 @@ fn compute_diff_stats(diff: &CommitDiff) -> (usize, usize) { struct GitGraphContextMenu { menu: Entity, position: Point, - entry_idx: usize, + entry_idx: Option, _subscription: Subscription, } @@ -1339,6 +1340,9 @@ pub struct GitGraph { context_menu: Option, table_interaction_state: Entity, column_widths: Entity, + /// Per-column visibility mask owned by the view (not the resize state) so columns can be + /// hidden regardless of whether the table is resizable. `true` means the column is hidden. + column_visibility: TableRow, selected_entry_idx: Option, hovered_entry_idx: Option, graph_canvas_bounds: Rc>>>, @@ -1402,22 +1406,32 @@ impl GitGraph { } fn preview_column_fractions(&self, window: &Window, cx: &App) -> [f32; 5] { - // todo(git_graph): We should make a column/table api that allows removing table columns - let fractions = self + let raw = self .column_widths .read(cx) .preview_fractions(window.rem_size()); + let fractions = redistribute_hidden_fractions(&raw, Some(&self.column_visibility)); + + // Hidden columns occupy no space in the layout, so report them as zero here even though + // the shared redistribution helper preserves their stored width for when they return. + let value = |idx: usize| { + if self.column_visibility.get(idx).copied().unwrap_or(false) { + 0.0 + } else { + fractions[idx] + } + }; let is_path_history = matches!(self.log_source, LogSource::Path(_)); - let graph_fraction = if is_path_history { 0.0 } else { fractions[0] }; + let graph_fraction = if is_path_history { 0.0 } else { value(0) }; let offset = if is_path_history { 0 } else { 1 }; [ graph_fraction, - fractions[offset], - fractions[offset + 1], - fractions[offset + 2], - fractions[offset + 3], + value(offset), + value(offset + 1), + value(offset + 2), + value(offset + 3), ] } @@ -1445,10 +1459,13 @@ impl GitGraph { } fn graph_viewport_width(&self, window: &Window, cx: &App) -> Pixels { - self.column_widths - .read(cx) - .preview_column_width(0, window) - .unwrap_or_else(|| self.graph_canvas_content_width()) + let container = self.column_widths.read(cx).cached_container_width(); + let graph_fraction = self.preview_column_fractions(window, cx)[0]; + if container > px(0.) && graph_fraction > 0.0 { + container * graph_fraction + } else { + self.graph_canvas_content_width() + } } pub fn new( @@ -1531,6 +1548,14 @@ impl GitGraph { ) }) }; + let column_visibility = TableRow::from_element( + false, + if matches!(log_source, LogSource::Path(_)) { + TABLE_COLUMN_COUNT + } else { + TABLE_COLUMN_COUNT + 1 + }, + ); let mut row_height = Self::row_height(window, cx); cx.observe_global_in::(window, move |this, window, cx| { @@ -1564,6 +1589,7 @@ impl GitGraph { context_menu: None, table_interaction_state, column_widths, + column_visibility, selected_entry_idx: None, hovered_entry_idx: None, graph_canvas_bounds: Rc::new(Cell::new(None)), @@ -2674,14 +2700,14 @@ impl GitGraph { menu }) }); - self.set_context_menu(context_menu, position, index, window, cx); + self.set_context_menu(context_menu, position, Some(index), window, cx); } fn set_context_menu( &mut self, context_menu: Entity, position: Point, - entry_idx: usize, + entry_idx: Option, window: &mut Window, cx: &mut Context, ) { @@ -2712,6 +2738,63 @@ impl GitGraph { cx.notify(); } + fn toggle_column_visibility(&mut self, col_idx: usize, cx: &mut Context) { + if let Some(slot) = self.column_visibility.as_mut_slice().get_mut(col_idx) { + *slot = !*slot; + // Column visibility is persisted per item, so schedule a workspace serialization. + cx.emit(ItemEvent::Edit); + } + } + + fn deploy_header_context_menu( + &mut self, + position: Point, + window: &mut Window, + cx: &mut Context, + ) { + let is_path_history = matches!(self.log_source, LogSource::Path(_)); + let columns: &[&str] = if is_path_history { + &["Description", "Date", "Author", "Commit"] + } else { + &["Graph", "Description", "Date", "Author", "Commit"] + }; + + let filter = self.column_visibility.clone(); + let visible_count = filter + .as_slice() + .iter() + .filter(|filtered| !**filtered) + .count(); + + let focus_handle = self.focus_handle.clone(); + let git_graph = cx.entity(); + let context_menu = ContextMenu::build(window, cx, |mut context_menu, _window, _cx| { + context_menu = context_menu.context(focus_handle).header("Columns"); + for (col_idx, label) in columns.iter().enumerate() { + let is_visible = !filter.get(col_idx).copied().unwrap_or(false); + // Disable hiding the last remaining visible column. + let can_toggle = !is_visible || visible_count > 1; + let git_graph = git_graph.clone(); + context_menu = context_menu.toggleable_entry_disabled_when( + label.to_string(), + is_visible, + !can_toggle, + IconPosition::End, + None, + move |_window, cx| { + git_graph.update(cx, |this, cx| { + this.toggle_column_visibility(col_idx, cx); + cx.notify(); + }); + }, + ); + } + context_menu + }); + + self.set_context_menu(context_menu, position, None, window, cx); + } + fn render_search_bar(&self, cx: &mut Context) -> impl IntoElement { let color = cx.theme().colors(); let query_focus_handle = self @@ -3365,7 +3448,7 @@ impl GitGraph { let hovered_entry_idx = self.hovered_entry_idx; let selected_entry_idx = self.selected_entry_idx; - let context_menu_entry_idx = self.context_menu.as_ref().map(|menu| menu.entry_idx); + let context_menu_entry_idx = self.context_menu.as_ref().and_then(|menu| menu.entry_idx); let is_focused = self.focus_handle.is_focused(window); let graph_canvas_bounds = self.graph_canvas_bounds.clone(); @@ -3892,10 +3975,27 @@ impl Render for GitGraph { let is_path_history = matches!(self.log_source, LogSource::Path(_)); let header_resize_info = HeaderResizeInfo::from_redistributable(&self.column_widths, cx); - let header_context = TableRenderContext::for_column_widths( - Some(self.column_widths.read(cx).widths_to_render()), - true, + + let column_filter = self.column_visibility.clone(); + + // The graph column (index 0) only exists in the non-path-history layout and is + // rendered as a separate canvas outside the table. + let graph_visible = + is_path_history || !column_filter.get(0usize).copied().unwrap_or(false); + + let table_offset = if is_path_history { 0 } else { 1 }; + let table_filter = column_filter + .as_slice() + .get(table_offset..table_offset + TABLE_COLUMN_COUNT) + .map(|slice| TableRow::from_vec(slice.to_vec(), TABLE_COLUMN_COUNT)) + .unwrap_or_else(|| TableRow::from_element(false, TABLE_COLUMN_COUNT)); + let header_widths = redistribute_hidden_widths( + &self.column_widths.read(cx).widths_to_render(), + Some(&column_filter), ); + let header_context = TableRenderContext::for_column_widths(Some(header_widths), true) + .with_column_filter(Some(column_filter)); + let [ graph_fraction, description_fraction, @@ -3907,6 +4007,9 @@ impl Render for GitGraph { description_fraction + date_fraction + author_fraction + commit_fraction; let table_width_config = self.table_column_width_config(window, cx); + let table_collapsed = table_fraction <= f32::EPSILON; + let graph_content_width = self.graph_canvas_content_width(); + h_flex() .size_full() .child( @@ -3916,47 +4019,69 @@ impl Render for GitGraph { .size_full() .flex() .flex_col() - .child(render_table_header( - if !is_path_history { - TableRow::from_vec( - vec![ - Label::new("Graph") - .color(Color::Muted) - .truncate() - .into_any_element(), - Label::new("Description") - .color(Color::Muted) - .into_any_element(), - Label::new("Date").color(Color::Muted).into_any_element(), - Label::new("Author").color(Color::Muted).into_any_element(), - Label::new("Commit").color(Color::Muted).into_any_element(), - ], - 5, + .child( + div() + .on_mouse_down( + MouseButton::Right, + cx.listener(|this, event: &MouseDownEvent, window, cx| { + this.deploy_header_context_menu(event.position, window, cx); + cx.stop_propagation(); + }), ) - } else { - TableRow::from_vec( - vec![ - Label::new("Description") - .color(Color::Muted) - .into_any_element(), - Label::new("Date").color(Color::Muted).into_any_element(), - Label::new("Author").color(Color::Muted).into_any_element(), - Label::new("Commit").color(Color::Muted).into_any_element(), - ], - 4, - ) - }, - header_context, - Some(header_resize_info), - Some(self.column_widths.entity_id()), - cx, - )) + .child(render_table_header( + if !is_path_history { + TableRow::from_vec( + vec![ + Label::new("Graph") + .color(Color::Muted) + .truncate() + .into_any_element(), + Label::new("Description") + .color(Color::Muted) + .into_any_element(), + Label::new("Date") + .color(Color::Muted) + .into_any_element(), + Label::new("Author") + .color(Color::Muted) + .into_any_element(), + Label::new("Commit") + .color(Color::Muted) + .into_any_element(), + ], + 5, + ) + } else { + TableRow::from_vec( + vec![ + Label::new("Description") + .color(Color::Muted) + .into_any_element(), + Label::new("Date") + .color(Color::Muted) + .into_any_element(), + Label::new("Author") + .color(Color::Muted) + .into_any_element(), + Label::new("Commit") + .color(Color::Muted) + .into_any_element(), + ], + 4, + ) + }, + header_context, + Some(header_resize_info), + Some(self.column_widths.entity_id()), + cx, + )), + ) .child({ let row_height = Self::row_height(window, cx); let selected_entry_idx = self.selected_entry_idx; let hovered_entry_idx = self.hovered_entry_idx; let context_menu_entry_idx = - self.context_menu.as_ref().map(|menu| menu.entry_idx); + self.context_menu.as_ref().and_then(|menu| menu.entry_idx); let weak_self = cx.weak_entity(); let focus_handle = self.focus_handle.clone(); let table_focus_handle = @@ -3991,6 +4116,7 @@ impl Render for GitGraph { .hide_row_borders() .hide_row_hover() .width_config(table_width_config) + .column_filter(table_filter) .map_row(move |(index, row), window, cx| { let is_selected = selected_entry_idx == Some(index); let is_hovered = hovered_entry_idx == Some(index); @@ -4077,10 +4203,18 @@ impl Render for GitGraph { .child( h_flex() .size_full() - .when(!is_path_history, |this| { + .when(!is_path_history && graph_visible, |this| { this.child( div() - .w(DefiniteLength::Fraction(graph_fraction)) + .map(|this| { + if table_collapsed { + this.w(graph_content_width) + } else { + this.w(DefiniteLength::Fraction( + graph_fraction, + )) + } + }) .h_full() .min_w_0() .overflow_hidden() @@ -4092,7 +4226,15 @@ impl Render for GitGraph { .tab_index(2) .tab_group() .tab_stop(false) - .w(DefiniteLength::Fraction(table_fraction)) + .map(|this| { + if table_collapsed { + this.flex_1() + } else { + this.w(DefiniteLength::Fraction( + table_fraction, + )) + } + }) .h_full() .min_w_0() .child(commits_table), @@ -4100,10 +4242,12 @@ impl Render for GitGraph { ) .child(render_redistributable_columns_resize_handles( &self.column_widths, + Some(&self.column_visibility), window, cx, )), self.column_widths.clone(), + Some(self.column_visibility.clone()), ) }), ) @@ -4295,6 +4439,7 @@ impl workspace::SerializableItem for GitGraph { selected_sha, search_query, search_case_sensitive, + hidden_columns, )) = db.get_git_graph(item_id, workspace_id).ok().flatten() else { return Task::ready(Err(anyhow::anyhow!("No git graph to deserialize"))); @@ -4307,6 +4452,7 @@ impl workspace::SerializableItem for GitGraph { selected_sha, search_query, search_case_sensitive, + hidden_columns, }; let window_handle = window.window_handle(); @@ -4349,6 +4495,16 @@ impl workspace::SerializableItem for GitGraph { }); git_graph.update(cx, |graph, cx| { + if let Some(bits) = state.hidden_columns { + let cols = graph.column_visibility.cols(); + let mask = persistence::deserialize_hidden_columns(bits, cols); + // Never restore an all-hidden mask (e.g. from corrupt data); the UI + // guarantees at least one column stays visible. + if mask.iter().any(|is_hidden| !is_hidden) { + graph.column_visibility = TableRow::from_vec(mask, cols); + } + } + graph.search_state.case_sensitive = state.search_case_sensitive.unwrap_or(false); @@ -4401,6 +4557,9 @@ impl workspace::SerializableItem for GitGraph { let log_source_value = persistence::serialize_log_source_value(&self.log_source); let log_order = Some(persistence::serialize_log_order(&self.log_order)); let search_case_sensitive = Some(self.search_state.case_sensitive); + let hidden_columns = Some(persistence::serialize_hidden_columns( + self.column_visibility.as_slice(), + )); let db = persistence::GitGraphsDb::global(cx); Some(cx.background_spawn(async move { @@ -4414,6 +4573,7 @@ impl workspace::SerializableItem for GitGraph { selected_sha, search_query, search_case_sensitive, + hidden_columns, ) .await })) @@ -4469,6 +4629,9 @@ mod persistence { ALTER TABLE git_graphs ADD COLUMN search_query TEXT; ALTER TABLE git_graphs ADD COLUMN search_case_sensitive INTEGER; ), + sql!( + ALTER TABLE git_graphs ADD COLUMN hidden_columns INTEGER; + ), ]; } @@ -4545,6 +4708,23 @@ mod persistence { } } + /// Packs the per-column visibility mask into a bitmask (bit `i` set means column `i` is + /// hidden), so it fits in a single integer database column regardless of column count. + pub fn serialize_hidden_columns(hidden: &[bool]) -> i32 { + hidden.iter().enumerate().fold( + 0, + |bits, (idx, &is_hidden)| { + if is_hidden { bits | (1 << idx) } else { bits } + }, + ) + } + + /// Inverse of [`serialize_hidden_columns`]. Bits beyond `cols` are ignored, and missing + /// bits default to visible, so a mask saved with a different column count degrades safely. + pub fn deserialize_hidden_columns(bits: i32, cols: usize) -> Vec { + (0..cols).map(|idx| bits & (1 << idx) != 0).collect() + } + #[derive(Debug, Default, Clone)] pub struct SerializedGitGraphState { pub log_source_type: Option, @@ -4553,6 +4733,7 @@ mod persistence { pub selected_sha: Option, pub search_query: Option, pub search_case_sensitive: Option, + pub hidden_columns: Option, } impl GitGraphsDb { @@ -4566,14 +4747,16 @@ mod persistence { log_order: Option, selected_sha: Option, search_query: Option, - search_case_sensitive: Option + search_case_sensitive: Option, + hidden_columns: Option ) -> Result<()> { INSERT OR REPLACE INTO git_graphs( item_id, workspace_id, repo_working_path, log_source_type, log_source_value, log_order, - selected_sha, search_query, search_case_sensitive + selected_sha, search_query, search_case_sensitive, + hidden_columns ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) } } @@ -4588,7 +4771,8 @@ mod persistence { Option, Option, Option, - Option + Option, + Option )>> { SELECT repo_working_path, @@ -4597,7 +4781,8 @@ mod persistence { log_order, selected_sha, search_query, - search_case_sensitive + search_case_sensitive, + hidden_columns FROM git_graphs WHERE item_id = ? AND workspace_id = ? } @@ -5886,6 +6071,7 @@ mod tests { selected_sha: Some(sha.to_string()), search_query: Some("fix bug".to_string()), search_case_sensitive: Some(true), + hidden_columns: None, }; assert_eq!( @@ -5910,6 +6096,7 @@ mod tests { selected_sha: None, search_query: None, search_case_sensitive: None, + hidden_columns: None, }; assert_eq!( persistence::deserialize_log_source(&all_state), @@ -5951,6 +6138,34 @@ mod tests { )); } + #[gpui::test] + fn test_hidden_columns_bitmask_roundtrip(_cx: &mut TestAppContext) { + let mask = [false, true, false, true, false]; + let bits = persistence::serialize_hidden_columns(&mask); + assert_eq!( + persistence::deserialize_hidden_columns(bits, mask.len()), + mask.to_vec() + ); + + assert_eq!(persistence::serialize_hidden_columns(&[false; 5]), 0); + assert_eq!( + persistence::deserialize_hidden_columns(0, 4), + vec![false; 4] + ); + + // A mask saved with more columns than we restore with is truncated safely, and one + // saved with fewer columns defaults the extra columns to visible. + let bits = persistence::serialize_hidden_columns(&[true, false, true, false, true]); + assert_eq!( + persistence::deserialize_hidden_columns(bits, 4), + vec![true, false, true, false] + ); + assert_eq!( + persistence::deserialize_hidden_columns(bits, 6), + vec![true, false, true, false, true, false] + ); + } + #[gpui::test] async fn test_git_graph_state_persists_across_serialization_roundtrip(cx: &mut TestAppContext) { init_test(cx); @@ -6026,6 +6241,9 @@ mod tests { .await .expect("should create workspace id"); let db = cx.read(|cx| persistence::GitGraphsDb::global(cx)); + // Hide the "Date" column (index 2 in the non-path-history layout). + let hidden_columns = + persistence::serialize_hidden_columns(&[false, false, true, false, false]); db.save_git_graph( item_id, workspace_id, @@ -6036,6 +6254,7 @@ mod tests { selected_sha.clone(), Some("some query".to_string()), Some(true), + Some(hidden_columns), ) .await .expect("save should succeed"); @@ -6089,6 +6308,12 @@ mod tests { graph.search_state.case_sensitive, true, "search case sensitivity should be restored" ); + + assert_eq!( + graph.column_visibility.as_slice(), + &[false, false, true, false, false], + "hidden columns should be restored" + ); }); restored_graph.read_with(&*cx, |graph, cx| { diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index 4b98199239b..2a3388a406a 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -610,9 +610,23 @@ impl ContextMenu { } pub fn toggleable_entry( + self, + label: impl Into, + toggled: bool, + position: IconPosition, + action: Option>, + handler: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + self.toggleable_entry_disabled_when(label, toggled, false, position, action, handler) + } + + /// Like [`Self::toggleable_entry`], but the entry is rendered disabled (and its handler is not + /// invoked) when `disabled` is `true`. + pub fn toggleable_entry_disabled_when( mut self, label: impl Into, toggled: bool, + disabled: bool, position: IconPosition, action: Option>, handler: impl Fn(&mut Window, &mut App) + 'static, @@ -629,7 +643,7 @@ impl ContextMenu { icon_size: IconSize::Small, icon_color: None, action, - disabled: false, + disabled, documentation_aside: None, end_slot_icon: None, end_slot_title: None, diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index 609ddef1d81..805c93708ac 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -7,8 +7,8 @@ use crate::{ RESIZE_DIVIDER_WIDTH, RedistributableColumnsState, RegisterComponent, RenderOnce, ScrollAxes, ScrollableHandle, Scrollbars, SharedString, StatefulInteractiveElement, Styled, StyledExt as _, StyledTypography, TableResizeBehavior, Window, WithScrollbar, bind_redistributable_columns, - div, example_group_with_title, h_flex, px, render_column_resize_divider, - render_redistributable_columns_resize_handles, single_example, + div, example_group_with_title, h_flex, px, redistribute_hidden_widths, + render_column_resize_divider, render_redistributable_columns_resize_handles, single_example, table_row::{IntoTableRow as _, TableRow}, v_flex, }; @@ -368,6 +368,9 @@ pub struct Table { cols: usize, disable_base_cell_style: bool, pinned_cols: usize, + /// Optional per-column visibility mask. When set, it overrides any filter derived from the + /// column width config. Columns whose entry is `true` are hidden. + column_filter: Option>, } impl Table { @@ -387,9 +390,20 @@ impl Table { disable_base_cell_style: false, column_width_config: ColumnWidthConfig::auto(), pinned_cols: 0, + column_filter: None, } } + /// Sets a per-column visibility mask. Columns whose entry is `true` are filtered out (hidden). + /// + /// This is useful when column visibility is driven by state that isn't part of the column + /// width config (for example a `Static`/`Explicit` width config). When set, this overrides + /// any filter derived from the column width config. + pub fn column_filter(mut self, filter: TableRow) -> Self { + self.column_filter = Some(filter); + self + } + /// Disables based styling of row cell (paddings, text ellipsis, nowrap, etc), keeping width settings /// /// Doesn't affect base style of header cell. @@ -646,18 +660,28 @@ pub fn render_table_row( }); let pinned_cols = table_context.pinned_cols; + let column_filter = &table_context.column_filter; if is_pinned_layout(pinned_cols, cols) { - let mut items_vec: Vec = items.map(IntoElement::into_any_element).into_vec(); - let mut widths_vec: Vec> = column_widths.into_vec(); + let items_vec: Vec = items.map(IntoElement::into_any_element).into_vec(); + let widths_vec: Vec> = column_widths.into_vec(); - let scrollable_items: Vec = items_vec.drain(pinned_cols..).collect(); - let scrollable_widths: Vec> = widths_vec.drain(pinned_cols..).collect(); + // Drop filtered columns before splitting into pinned/scrollable sections. The number of + // pinned columns that survive filtering determines where the kept cells are split. + let pinned_visible = (0..pinned_cols) + .filter(|&idx| column_is_visible(column_filter, idx)) + .count(); + let mut kept: Vec<(AnyElement, Option)> = items_vec + .into_iter() + .zip(widths_vec) + .enumerate() + .filter(|(idx, _)| column_is_visible(column_filter, *idx)) + .map(|(_, pair)| pair) + .collect(); + let scrollable: Vec<(AnyElement, Option)> = kept.drain(pinned_visible..).collect(); let pinned_section = div().flex().flex_row().flex_shrink_0().children( - items_vec - .into_iter() - .zip(widths_vec) + kept.into_iter() .map(|(cell, width)| render_cell(width, cell, &table_context, cx)), ); @@ -671,9 +695,8 @@ pub fn render_table_row( .flex() .child( div().flex().flex_row().children( - scrollable_items + scrollable .into_iter() - .zip(scrollable_widths) .map(|(cell, width)| render_cell(width, cell, &table_context, cx)), ), ); @@ -691,7 +714,9 @@ pub fn render_table_row( .into_vec() .into_iter() .zip(column_widths.into_vec()) - .map(|(cell, width)| render_cell(width, cell, &table_context, cx)), + .enumerate() + .filter(|(idx, _)| column_is_visible(column_filter, *idx)) + .map(|(_, (cell, width))| render_cell(width, cell, &table_context, cx)), ); } @@ -735,52 +760,61 @@ pub fn render_table_header( let use_ui_font = table_context.use_ui_font; let resize_info_ref = resize_info.as_ref(); + let column_filter = &table_context.column_filter; if is_pinned_layout(pinned_cols, cols) { - let mut headers_vec: Vec = headers + let headers_vec: Vec = headers .into_vec() .into_iter() .map(IntoElement::into_any_element) .collect(); - let mut widths_vec: Vec> = column_widths.into_vec(); + let widths_vec: Vec> = column_widths.into_vec(); - let scrollable_headers: Vec = headers_vec.drain(pinned_cols..).collect(); - let scrollable_widths: Vec> = widths_vec.drain(pinned_cols..).collect(); + // Keep the original column index alongside each visible header so that resize info + // (which is indexed by original column position) stays correct after filtering. + let pinned_visible = (0..pinned_cols) + .filter(|&idx| column_is_visible(column_filter, idx)) + .count(); + let mut kept: Vec<(usize, AnyElement, Option)> = headers_vec + .into_iter() + .zip(widths_vec) + .enumerate() + .filter(|(idx, _)| column_is_visible(column_filter, *idx)) + .map(|(idx, (h, width))| (idx, h, width)) + .collect(); + let scrollable: Vec<(usize, AnyElement, Option)> = + kept.drain(pinned_visible..).collect(); let pinned_section = - div().flex().flex_row().flex_shrink_0().children( - headers_vec.into_iter().enumerate().zip(widths_vec).map( - |((header_idx, h), width)| { - render_header_cell( - h, - width, - header_idx, - &shared_element_id, - resize_info_ref, - use_ui_font, - cx, - ) - }, - ), - ); - - let inner = div().flex().flex_row().children( - scrollable_headers - .into_iter() - .enumerate() - .zip(scrollable_widths) - .map(|((rel_idx, h), width)| { + div() + .flex() + .flex_row() + .flex_shrink_0() + .children(kept.into_iter().map(|(header_idx, h, width)| { render_header_cell( h, width, - pinned_cols + rel_idx, + header_idx, &shared_element_id, resize_info_ref, use_ui_font, cx, ) - }), - ); + })); + + let inner = div().flex().flex_row().children(scrollable.into_iter().map( + |(header_idx, h, width)| { + render_header_cell( + h, + width, + header_idx, + &shared_element_id, + resize_info_ref, + use_ui_font, + cx, + ) + }, + )); let mut scrollable_section = div() .id("table-header-scrollable") .flex_grow_1() @@ -805,6 +839,7 @@ pub fn render_table_header( .into_iter() .enumerate() .zip(column_widths.into_vec()) + .filter(|((idx, _), _)| column_is_visible(column_filter, *idx)) .map(|((header_idx, h), width)| { render_header_cell( h.into_any_element(), @@ -832,6 +867,9 @@ pub struct TableRenderContext { pub use_ui_font: bool, pub disable_base_cell_style: bool, pub pinned_cols: usize, + /// Per-column visibility mask. When `Some`, columns whose entry is `true` are filtered + /// out (hidden) and not rendered. Indices map to the table's original column positions. + pub column_filter: Option>, /// Scroll handle shared by all scrollable sections in rows and headers. /// When `pinned_cols > 0`, each row's scrollable section tracks this handle so all rows /// scroll together without requiring per-scroll re-renders. @@ -845,11 +883,15 @@ impl TableRenderContext { show_row_borders: table.show_row_borders, show_row_hover: table.show_row_hover, total_row_count: table.rows.len(), - column_widths: table.column_width_config.widths_to_render(cx), + column_widths: table + .column_width_config + .widths_to_render(cx) + .map(|widths| redistribute_hidden_widths(&widths, table.column_filter.as_ref())), map_row: table.map_row.clone(), use_ui_font: table.use_ui_font, disable_base_cell_style: table.disable_base_cell_style, pinned_cols: table.pinned_cols, + column_filter: table.column_filter.clone(), h_scroll_handle, } } @@ -865,9 +907,23 @@ impl TableRenderContext { use_ui_font, disable_base_cell_style: false, pinned_cols: 0, + column_filter: None, h_scroll_handle: None, } } + + /// Sets the per-column visibility mask. Columns whose entry is `true` are hidden. + pub fn with_column_filter(mut self, column_filter: Option>) -> Self { + self.column_filter = column_filter; + self + } +} + +/// Returns `true` when the column at `col_idx` should be rendered (i.e. not filtered out). +fn column_is_visible(filter: &Option>, col_idx: usize) -> bool { + filter + .as_ref() + .map_or(true, |mask| !mask.get(col_idx).copied().unwrap_or(false)) } /// Builds resize dividers for the given column range, positioned absolutely from `left: 0`. @@ -1090,6 +1146,7 @@ impl RenderOnce for Table { None, Some(render_redistributable_columns_resize_handles( columns_state, + self.column_filter.as_ref(), window, cx, )), @@ -1127,7 +1184,7 @@ impl RenderOnce for Table { )) }) .when_some(redistributable_entity, |this, widths| { - bind_redistributable_columns(this, widths) + bind_redistributable_columns(this, widths, self.column_filter.clone()) }) .when_some(resizable_entity, |this, entity| { let scroll_handle_for_drag = h_scroll_handle.clone(); diff --git a/crates/ui/src/components/data_table/table_row.rs b/crates/ui/src/components/data_table/table_row.rs index 9ef75e4cbbb..94d9910df33 100644 --- a/crates/ui/src/components/data_table/table_row.rs +++ b/crates/ui/src/components/data_table/table_row.rs @@ -74,6 +74,10 @@ impl TableRow { &self.0 } + pub fn as_mut_slice(&mut self) -> &mut [T] { + &mut self.0 + } + pub fn into_vec(self) -> Vec { self.0 } diff --git a/crates/ui/src/components/data_table/tests.rs b/crates/ui/src/components/data_table/tests.rs index 509cceca712..652ef47467f 100644 --- a/crates/ui/src/components/data_table/tests.rs +++ b/crates/ui/src/components/data_table/tests.rs @@ -319,6 +319,130 @@ mod drag_handle { ); } +mod drag_with_hidden_columns { + use super::*; + + // Dragging with hidden (filtered) columns: the resize dividers are laid out using the + // *redistributed* (visible-only) widths, so `compute_drag_preview` must do its geometry in + // that same space and skip hidden columns when propagating the resize to a neighbor. + + /// Mirrors how the renderer turns raw widths into the on-screen layout: hidden columns + /// collapse to zero and their space is redistributed across the visible columns. + fn redistributed(widths: &[f32], hidden: &[bool]) -> Vec { + let visible_sum: f32 = widths + .iter() + .zip(hidden) + .filter(|(_, is_hidden)| !**is_hidden) + .map(|(width, _)| *width) + .sum(); + widths + .iter() + .zip(hidden) + .map(|(width, is_hidden)| { + if *is_hidden { + 0.0 + } else { + *width / visible_sum + } + }) + .collect() + } + + #[test] + fn drag_without_hidden_columns_is_unchanged() { + // Guards the pre-existing behavior: with no hidden mask the drag operates on the raw + // widths directly (the visible space and the raw space are the same). + let resize_behavior = TableRow::from_vec(vec![TableResizeBehavior::Resizable; 3], 3); + let widths = TableRow::from_vec(vec![1.0 / 3.0; 3], 3); + + let result = RedistributableColumnsState::compute_drag_preview( + widths, + &resize_behavior, + None, + 1, + 0.8, + 0.0, + ); + + let result = result.as_slice(); + let boundary = result[0] + result[1]; + assert!( + (boundary - 0.8).abs() < 1e-6, + "expected the boundary after column 1 to follow the cursor to 0.8: {result:?}", + ); + assert!( + (result[0] - 1.0 / 3.0).abs() < 1e-6, + "column 0 must not be affected: {result:?}", + ); + } + + #[test] + fn drag_boundary_follows_cursor_with_hidden_column() { + // Three equal columns; column 0 is hidden. The two visible columns each render at 0.5 + // of the container. The user grabs the divider between the visible columns (original + // index 1) and drags the cursor to 70% of the container. The boundary between the + // visible columns should follow the cursor to 0.7. + let resize_behavior = TableRow::from_vec(vec![TableResizeBehavior::Resizable; 3], 3); + let widths = TableRow::from_vec(vec![1.0 / 3.0; 3], 3); + let hidden = [true, false, false]; + let hidden_mask = TableRow::from_vec(hidden.to_vec(), 3); + + let result = RedistributableColumnsState::compute_drag_preview( + widths, + &resize_behavior, + Some(&hidden_mask), + 1, + 0.7, + 0.0, + ); + + let rendered = redistributed(result.as_slice(), &hidden); + assert!( + (rendered[1] - 0.7).abs() < 1e-3, + "expected the visible boundary to follow the cursor to 0.7, got {} (raw {:?})", + rendered[1], + result.as_slice(), + ); + } + + #[test] + fn drag_does_not_resize_hidden_neighbor() { + // Three equal columns; the middle column (1) is hidden. The only divider the user can + // grab sits between visible columns 0 and 2 (original index 0). Dragging it must resize + // the next *visible* column (2) and leave the hidden column's width untouched. + let resize_behavior = TableRow::from_vec(vec![TableResizeBehavior::Resizable; 3], 3); + let widths = TableRow::from_vec(vec![1.0 / 3.0; 3], 3); + let hidden = [false, true, false]; + let hidden_mask = TableRow::from_vec(hidden.to_vec(), 3); + + let result = RedistributableColumnsState::compute_drag_preview( + widths, + &resize_behavior, + Some(&hidden_mask), + 0, + 0.7, + 0.0, + ); + + let result = result.as_slice(); + assert!( + (result[1] - 1.0 / 3.0).abs() < 1e-6, + "hidden column width must be preserved, but it changed: {result:?}", + ); + // The drag moved width from visible column 2 to visible column 0, so the total is + // unchanged and the next *visible* column absorbed the resize. + let total: f32 = result.iter().sum(); + assert!( + (total - 1.0).abs() < 1e-6, + "total must be preserved: {result:?}" + ); + assert!( + result[0] > 1.0 / 3.0 && result[2] < 1.0 / 3.0, + "expected the resize to be absorbed by the next visible column: {result:?}", + ); + } +} + mod resizable_drag { use super::*; @@ -419,3 +543,115 @@ mod pin_layout { assert!(is_pinned_layout(4, 5)); } } + +mod column_filter { + use super::super::column_is_visible; + use super::*; + use crate::{redistribute_hidden_fractions, redistribute_hidden_widths}; + use gpui::{DefiniteLength, Length}; + + fn frac_row(values: &[f32]) -> TableRow { + TableRow::from_vec(values.to_vec(), values.len()) + } + + fn hidden_row(values: &[bool]) -> TableRow { + TableRow::from_vec(values.to_vec(), values.len()) + } + + fn width_row(values: &[f32]) -> TableRow { + TableRow::from_vec( + values + .iter() + .map(|fraction| Length::Definite(DefiniteLength::Fraction(*fraction))) + .collect(), + values.len(), + ) + } + + fn width_fractions(widths: &TableRow) -> Vec { + widths + .as_slice() + .iter() + .map(|length| match length { + Length::Definite(DefiniteLength::Fraction(fraction)) => *fraction, + other => panic!("expected fraction, got {other:?}"), + }) + .collect() + } + + #[test] + fn column_is_visible_respects_mask() { + let filter = Some(hidden_row(&[false, true, false])); + assert!(column_is_visible(&filter, 0)); + assert!(!column_is_visible(&filter, 1)); + assert!(column_is_visible(&filter, 2)); + // Indices outside the mask default to visible. + assert!(column_is_visible(&filter, 5)); + } + + #[test] + fn column_is_visible_without_filter_is_always_visible() { + let filter: Option> = None; + assert!(column_is_visible(&filter, 0)); + assert!(column_is_visible(&filter, 100)); + } + + #[test] + fn redistribute_widths_is_identity_without_hidden_columns() { + let widths = width_row(&[0.25, 0.25, 0.25, 0.25]); + assert_eq!( + width_fractions(&redistribute_hidden_widths(&widths, None)), + vec![0.25, 0.25, 0.25, 0.25] + ); + + let none_hidden = hidden_row(&[false, false, false, false]); + assert_eq!( + width_fractions(&redistribute_hidden_widths(&widths, Some(&none_hidden))), + vec![0.25, 0.25, 0.25, 0.25] + ); + } + + #[test] + fn redistribute_widths_scales_visible_columns_to_fill() { + let widths = width_row(&[0.25, 0.25, 0.25, 0.25]); + let hidden = hidden_row(&[false, true, false, false]); + let result = width_fractions(&redistribute_hidden_widths(&widths, Some(&hidden))); + + // The hidden column keeps its stored width rather than being zeroed out (it is simply + // not rendered), so its width is restored intact when it is shown again. + assert_eq!(result[1], 0.25); + // The visible columns expand to fill the container. + let visible_sum: f32 = result[0] + result[2] + result[3]; + assert!( + (visible_sum - 1.0).abs() < 1e-6, + "visible sum was {visible_sum}" + ); + // Equal initial fractions stay equal after redistribution. + assert!((result[0] - result[2]).abs() < 1e-6); + assert!((result[0] - result[3]).abs() < 1e-6); + } + + #[test] + fn redistribute_fractions_scales_visible_columns_to_fill() { + let fractions = frac_row(&[0.25, 0.25, 0.25, 0.25]); + let hidden = hidden_row(&[false, true, false, false]); + let result = redistribute_hidden_fractions(&fractions, Some(&hidden)); + let result = result.as_slice(); + + assert_eq!(result[1], 0.25); + let visible_sum: f32 = result[0] + result[2] + result[3]; + assert!( + (visible_sum - 1.0).abs() < 1e-6, + "visible sum was {visible_sum}" + ); + } + + #[test] + fn redistribute_fractions_is_identity_without_hidden_columns() { + let fractions = frac_row(&[0.2, 0.3, 0.5]); + assert_eq!( + redistribute_hidden_fractions(&fractions, None).as_slice(), + &[0.2, 0.3, 0.5] + ); + } +} diff --git a/crates/ui/src/components/redistributable_columns.rs b/crates/ui/src/components/redistributable_columns.rs index cb5da35d565..0fa93c8ba35 100644 --- a/crates/ui/src/components/redistributable_columns.rs +++ b/crates/ui/src/components/redistributable_columns.rs @@ -1,11 +1,3 @@ -use std::rc::Rc; - -use gpui::{ - AbsoluteLength, AppContext as _, Bounds, DefiniteLength, DragMoveEvent, Empty, Entity, - EntityId, Length, Stateful, WeakEntity, -}; -use itertools::intersperse_with; - use super::data_table::{ ResizableColumnsState, table_row::{IntoTableRow as _, TableRow}, @@ -15,6 +7,11 @@ use crate::{ IntoElement, ParentElement, Pixels, StatefulInteractiveElement, Styled, Window, div, h_flex, px, }; +use gpui::{ + AbsoluteLength, AppContext as _, Bounds, DefiniteLength, DragMoveEvent, Empty, Entity, + EntityId, Length, Stateful, WeakEntity, +}; +use std::rc::Rc; pub(crate) const RESIZE_COLUMN_WIDTH: f32 = 8.0; pub(crate) const RESIZE_DIVIDER_WIDTH: f32 = 1.0; @@ -270,6 +267,7 @@ impl RedistributableColumnsState { fn on_drag_move( &mut self, drag_event: &DragMoveEvent, + hidden: Option<&TableRow>, window: &mut Window, cx: &mut Context, ) { @@ -280,7 +278,6 @@ impl RedistributableColumnsState { return; } - let mut col_position = 0.0; let rem_size = window.rem_size(); let col_idx = drag_event.drag(cx).col_idx; @@ -290,28 +287,107 @@ impl RedistributableColumnsState { rem_size, ); - let mut widths = self + let widths = self .committed_widths .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size)); - for length in widths[0..=col_idx].iter() { + let drag_fraction = (drag_position.x - bounds.left()) / bounds_width; + + let widths = Self::compute_drag_preview( + widths, + &self.resize_behavior, + hidden, + col_idx, + drag_fraction, + divider_width, + ); + + self.preview_widths = widths.map(DefiniteLength::Fraction); + } + + /// Computes the preview column fractions produced by dragging the divider after `col_idx` + /// to `drag_fraction` (the cursor's x position expressed as a fraction of the container + /// width). `divider_width` is the resize-divider width as a fraction of the container. + /// + /// The on-screen layout only contains the visible columns, with the hidden columns' width + /// budget redistributed across them (see [`redistribute_hidden_widths`]), so the geometry + /// here is done in that visible/redistributed space: the raw `widths` are compacted to the + /// visible columns and scaled to match the rendered layout, the drag is applied there (which + /// also makes neighbor propagation skip hidden columns), and the result is mapped back to + /// the raw widths, leaving hidden columns untouched. + /// + /// Extracted as a pure function so the drag math can be unit tested, mirroring the + /// `drag_column_handle` / `propagate_resize_diff` helpers. + pub(crate) fn compute_drag_preview( + mut widths: TableRow, + resize_behavior: &TableRow, + hidden: Option<&TableRow>, + col_idx: usize, + drag_fraction: f32, + divider_width: f32, + ) -> TableRow { + let visible_cols: Vec = (0..widths.cols()) + .filter(|idx| !is_column_hidden(hidden, *idx)) + .collect(); + + // Dividers are only rendered after visible columns, so a hidden `col_idx` should be + // impossible; bail out rather than resizing the wrong column. + let Some(divider_position) = visible_cols.iter().position(|&idx| idx == col_idx) else { + return widths; + }; + + let total_sum: f32 = widths.as_slice().iter().sum(); + let visible_sum: f32 = visible_cols.iter().map(|&idx| widths[idx]).sum(); + // The drag only moves width between visible columns, so `visible_sum` (and therefore + // this scale) is the same before and after the drag, making the mapping back exact. + let scale = if visible_sum > 0.0 { + total_sum / visible_sum + } else { + 1.0 + }; + + let mut rendered_widths = TableRow::from_vec( + visible_cols + .iter() + .map(|&idx| widths[idx] * scale) + .collect(), + visible_cols.len(), + ); + let rendered_behavior = TableRow::from_vec( + visible_cols + .iter() + .map(|&idx| resize_behavior[idx]) + .collect(), + visible_cols.len(), + ); + + let mut col_position = 0.0; + for length in rendered_widths[0..=divider_position].iter() { col_position += length + divider_width; } let mut total_length_ratio = col_position; - for length in widths[col_idx + 1..].iter() { + for length in rendered_widths[divider_position + 1..].iter() { total_length_ratio += length; } - let cols = self.resize_behavior.cols(); - total_length_ratio += (cols - 1 - col_idx) as f32 * divider_width; + let cols = rendered_behavior.cols(); + total_length_ratio += (cols - 1 - divider_position) as f32 * divider_width; - let drag_fraction = (drag_position.x - bounds.left()) / bounds_width; let drag_fraction = drag_fraction * total_length_ratio; let diff = drag_fraction - col_position - divider_width / 2.0; - Self::drag_column_handle(diff, col_idx, &mut widths, &self.resize_behavior); + Self::drag_column_handle( + diff, + divider_position, + &mut rendered_widths, + &rendered_behavior, + ); - self.preview_widths = widths.map(DefiniteLength::Fraction); + for (visible_position, &idx) in visible_cols.iter().enumerate() { + widths[idx] = rendered_widths[visible_position] / scale; + } + + widths } pub(crate) fn drag_column_handle( @@ -385,9 +461,99 @@ impl RedistributableColumnsState { } } +/// Returns `true` when the column at `idx` is hidden by `hidden`. +pub fn is_column_hidden(hidden: Option<&TableRow>, idx: usize) -> bool { + hidden + .and_then(|mask| mask.get(idx).copied()) + .unwrap_or(false) +} + +/// Redistributes the fractional width budget of hidden columns across the visible columns so the +/// visible columns fill the container instead of leaving a gap. Hidden columns keep their stored +/// width (they are never rendered, so the value is not shown) and `Absolute` widths are left +/// untouched. Returns the widths unchanged when no column is hidden. +pub fn redistribute_hidden_widths( + widths: &TableRow, + hidden: Option<&TableRow>, +) -> TableRow { + if !(0..widths.cols()).any(|idx| is_column_hidden(hidden, idx)) { + return widths.clone(); + } + + let mut total_fraction_sum = 0.0; + let mut visible_fraction_sum = 0.0; + for (idx, width) in widths.as_slice().iter().enumerate() { + if let Length::Definite(DefiniteLength::Fraction(fraction)) = width { + total_fraction_sum += *fraction; + if !is_column_hidden(hidden, idx) { + visible_fraction_sum += *fraction; + } + } + } + let scale = if visible_fraction_sum > 0.0 { + total_fraction_sum / visible_fraction_sum + } else { + 1.0 + }; + + let scaled: Vec = widths + .as_slice() + .iter() + .enumerate() + .map(|(idx, width)| match width { + Length::Definite(DefiniteLength::Fraction(fraction)) + if !is_column_hidden(hidden, idx) => + { + Length::Definite(DefiniteLength::Fraction(fraction * scale)) + } + other => *other, + }) + .collect(); + TableRow::from_vec(scaled, widths.cols()) +} + +/// Fraction-valued counterpart of [`redistribute_hidden_widths`]. +pub fn redistribute_hidden_fractions( + fractions: &TableRow, + hidden: Option<&TableRow>, +) -> TableRow { + if !(0..fractions.cols()).any(|idx| is_column_hidden(hidden, idx)) { + return fractions.clone(); + } + + let total_sum: f32 = fractions.as_slice().iter().sum(); + let visible_sum: f32 = fractions + .as_slice() + .iter() + .enumerate() + .filter(|(idx, _)| !is_column_hidden(hidden, *idx)) + .map(|(_, fraction)| *fraction) + .sum(); + let scale = if visible_sum > 0.0 { + total_sum / visible_sum + } else { + 1.0 + }; + + let scaled: Vec = fractions + .as_slice() + .iter() + .enumerate() + .map(|(idx, fraction)| { + if is_column_hidden(hidden, idx) { + *fraction + } else { + fraction * scale + } + }) + .collect(); + TableRow::from_vec(scaled, fractions.cols()) +} + pub fn bind_redistributable_columns( container: Div, columns_state: Entity, + hidden: Option>, ) -> Div { container .on_drag_move::({ @@ -397,7 +563,7 @@ pub fn bind_redistributable_columns( return; } columns_state.update(cx, |columns, cx| { - columns.on_drag_move(event, window, cx); + columns.on_drag_move(event, hidden.as_ref(), window, cx); }); } }) @@ -420,65 +586,70 @@ pub fn bind_redistributable_columns( pub fn render_redistributable_columns_resize_handles( columns_state: &Entity, + hidden: Option<&TableRow>, window: &mut Window, cx: &mut App, ) -> AnyElement { let (column_widths, resize_behavior) = { let state = columns_state.read(cx); - (state.widths_to_render(), state.resize_behavior().clone()) + ( + redistribute_hidden_widths(&state.widths_to_render(), hidden), + state.resize_behavior().clone(), + ) }; - let mut column_ix = 0; - let resize_behavior = Rc::new(resize_behavior); - let dividers = intersperse_with( - column_widths - .as_slice() - .iter() - .copied() - .map(|width| resize_spacer(width).into_any_element()), - || { - let current_column_ix = column_ix; - let resize_behavior = Rc::clone(&resize_behavior); - let columns_state = columns_state.clone(); - column_ix += 1; + // Only the visible columns participate in the layout; filtered columns are skipped entirely + // (no spacer, no divider) so we don't draw a stray resize line where a hidden column was. + let visible_cols: Vec = (0..column_widths.cols()) + .filter(|idx| !is_column_hidden(hidden, *idx)) + .collect(); - { - let divider = div().id(current_column_ix).relative().top_0(); - let entity_id = columns_state.entity_id(); - let on_reset: Rc = { - let columns_state = columns_state.clone(); - Rc::new(move |window, cx| { - columns_state.update(cx, |columns, cx| { - columns.reset_column_to_initial_width(current_column_ix, window); - cx.notify(); - }); - }) - }; - let on_drag_end: Option> = { - Some(Rc::new(move |cx| { - columns_state.update(cx, |state, _| state.commit_preview()); - })) - }; - render_column_resize_divider( - divider, - current_column_ix, - resize_behavior[current_column_ix].is_resizable(), - entity_id, - on_reset, - on_drag_end, - window, - cx, - ) - } - }, - ); + let mut children: Vec = Vec::with_capacity(visible_cols.len() * 2); + for (position, &col_idx) in visible_cols.iter().enumerate() { + children.push(resize_spacer(column_widths[col_idx]).into_any_element()); + + // A divider is rendered after every visible column except the last, mirroring the + // original `intersperse` behavior but in terms of visible columns. + let is_last_visible = position + 1 == visible_cols.len(); + if is_last_visible { + continue; + } + + let columns_state = columns_state.clone(); + let divider = div().id(col_idx).relative().top_0(); + let entity_id = columns_state.entity_id(); + let on_reset: Rc = { + let columns_state = columns_state.clone(); + Rc::new(move |window, cx| { + columns_state.update(cx, |columns, cx| { + columns.reset_column_to_initial_width(col_idx, window); + cx.notify(); + }); + }) + }; + let on_drag_end: Option> = { + Some(Rc::new(move |cx| { + columns_state.update(cx, |state, _| state.commit_preview()); + })) + }; + children.push(render_column_resize_divider( + divider, + col_idx, + resize_behavior[col_idx].is_resizable(), + entity_id, + on_reset, + on_drag_end, + window, + cx, + )); + } h_flex() .id("resize-handles") .absolute() .inset_0() .w_full() - .children(dividers) + .children(children) .into_any_element() } From a29c0d41f4787d7cd268d4f8ad1a706af86042c7 Mon Sep 17 00:00:00 2001 From: Philipp Schaffrath Date: Tue, 7 Jul 2026 10:54:07 +0200 Subject: [PATCH 104/197] gpui: Add input region support for Wayland windows (#60161) # Objective Wayland windows have no way to restrict which parts of the surface accept pointer and touch input. This adds support for setting an input region, so events outside it pass through to whatever is below the window. This is useful for shaped or partially click-through windows. Clicks in green area can pass through the window, clicks in red area do not: image ## Solution Add `Window::set_input_region`, which takes `Option<&[Bounds]>`: - `Some(rects)` restricts pointer and touch input to the union of the rectangles, in window coordinates. - `Some(&[])` is an empty region, so the window receives no input at all and is fully click-through. - `None` resets the region to the default, so the whole window receives input again. On Wayland this maps to `wl_surface.set_input_region`, building a `wl_region` from the rectangles or clearing it for `None`, and commits so the change applies immediately rather than waiting for the next frame. The method is a no-op on other platforms. ## Testing Tested on Linux with Wayland. - Tested in my own GPUI application, which uses a fullscreen layer for overlays while allowing clicks outside of the rendered elements to be passed through to the underlying windows. - No automated test was added, since this calls through to the compositor and is checked by observing input routing. ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - N/A --- crates/gpui/src/platform.rs | 1 + crates/gpui/src/window.rs | 10 ++++++ crates/gpui_linux/src/linux/wayland/window.rs | 32 +++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 5765fd80aff..13fcbcee7a7 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -702,6 +702,7 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn show_window_menu(&self, _position: Point) {} fn start_window_move(&self) {} fn start_window_resize(&self, _edge: ResizeEdge) {} + fn set_input_region(&self, _region: Option<&[Bounds]>) {} fn window_decorations(&self) -> Decorations { Decorations::Server } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 198fab268e8..fcf10ac5576 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1999,6 +1999,16 @@ impl Window { self.platform_window.start_window_resize(edge); } + /// Linux (wayland) only: Set the window's input region, the area that receives pointer + /// and touch input. Events outside it pass through to whatever is below the window. + /// + /// - `Some(rects)` restricts input to the union of `rects`, in window coordinates. + /// - `Some(&[])` is an empty region, so the window receives no pointer or touch input. + /// - `None` resets the region to the default, so the whole window receives input again. + pub fn set_input_region(&self, region: Option<&[Bounds]>) { + self.platform_window.set_input_region(region); + } + /// Return the `WindowBounds` to indicate that how a window should be opened /// after it has been closed pub fn window_bounds(&self) -> WindowBounds { diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index da759cfebc7..731569a025f 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -1489,6 +1489,38 @@ impl PlatformWindow for WaylandWindow { } } + fn set_input_region(&self, region: Option<&[Bounds]>) { + let state = self.borrow(); + match region { + // No region means the whole surface receives input. + None => state.surface.set_input_region(None), + // A region restricts input to its rectangles. An empty region + // receives no input at all. + Some(rects) => { + let wl_region = state + .globals + .compositor + .create_region(&state.globals.qh, ()); + for rect in rects { + let rect = rect.map(|pixels| f32::from(pixels) as i32); + wl_region.add( + rect.origin.x, + rect.origin.y, + rect.size.width, + rect.size.height, + ); + } + state.surface.set_input_region(Some(&wl_region)); + wl_region.destroy(); + } + } + + // Commit so the new input region applies immediately. Otherwise it + // waits for the next frame, which could be the very click we want to + // allow passing through. + state.surface.commit(); + } + fn window_decorations(&self) -> Decorations { let state = self.borrow(); match state.decorations { From e7803a88f55e290a0478d46bd0137e711d6da862 Mon Sep 17 00:00:00 2001 From: Nick Mosher Date: Tue, 7 Jul 2026 01:59:43 -0700 Subject: [PATCH 105/197] gpui: Add ParentElement impl for AnimationElement (#54145) Hello, I've been loving using GPUI! Recently I noticed that calling `.with_animiation()` would give an `AnimationElement` which did not allow `.child()` to be called on it. It was a quick fix, I hope it's a quick easy merge but let me know if you'd like anything changed :) 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: Lukas Wirth --- crates/gpui/src/elements/animation.rs | 43 ++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/crates/gpui/src/elements/animation.rs b/crates/gpui/src/elements/animation.rs index 8a42c8bd492..b816f7dd935 100644 --- a/crates/gpui/src/elements/animation.rs +++ b/crates/gpui/src/elements/animation.rs @@ -2,7 +2,8 @@ use scheduler::Instant; use std::{rc::Rc, time::Duration}; use crate::{ - AnyElement, App, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, Window, + AnyElement, App, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, + ParentElement, Window, }; pub use easing::*; @@ -95,6 +96,16 @@ pub struct AnimationElement { animator: Box E + 'static>, } +impl ParentElement for AnimationElement { + fn extend(&mut self, elements: impl IntoIterator) { + let Some(element) = &mut self.element else { + return; + }; + + element.extend(elements); + } +} + impl AnimationElement { /// Returns a new [`AnimationElement`] after applying the given function /// to the element being animated. @@ -259,3 +270,33 @@ mod easing { } } } + +#[cfg(test)] +mod tests { + use crate::InteractiveElement; + use crate::div; + + use super::*; + + // Before parent-animation-element, using .with_animation + // would not allow chaining .parent after. This is just a + // build check that we can call div().id().with_animation().child() + #[test] + fn test_animation_parent() { + div() + .id("id") + // + .with_animation( + "animation", + Animation::new(Duration::from_secs(1)), + |el, _t| { + // + el + }, + ) + .child( + // + div(), + ); + } +} From 20a93f6195ca8e9f0a748038317a5efe1be3e482 Mon Sep 17 00:00:00 2001 From: Ankan Misra Date: Tue, 7 Jul 2026 14:29:55 +0530 Subject: [PATCH 106/197] gpui_macos: Fix glyph rendering when fonts share a PostScript name (#57250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS, `load_family` was inserting every font into `font_ids_by_postscript_name` without checking for duplicates. When two installed font files claim the same PostScript name — typically an older Geist Mono left behind alongside the current brew cask — the second insert overwrote the first. After shaping, `id_for_native_font` looked up that PostScript name and got back the *other* font's `FontId`, so the rasterizer drew glyphs from the wrong table See #55472 for the screenshot This adds a local `HashSet` to dedup within a single `load_family` call. Scoping it to one call (rather than the global map) matters: the same family gets reloaded under different `FontKey`s when `FontFeatures` or `FontFallbacks` change, and a global check would skip every font on the reload and break weight selection. Cross-family PostScript name collisions and CoreText fallback substitutions that re-introduce a conflict are out of scope here 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 glyph rendering when fonts share a PostScript name on macOS --------- Co-authored-by: Lukas Wirth --- crates/gpui_macos/src/text_system.rs | 32 ++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/gpui_macos/src/text_system.rs b/crates/gpui_macos/src/text_system.rs index 2808e535ad5..fbc48864ce5 100644 --- a/crates/gpui_macos/src/text_system.rs +++ b/crates/gpui_macos/src/text_system.rs @@ -1,6 +1,6 @@ use anyhow::anyhow; use cocoa::appkit::CGFloat; -use collections::HashMap; +use collections::{HashMap, HashSet}; use core_foundation::{ array::{CFArray, CFArrayRef}, attributed_string::CFMutableAttributedString, @@ -282,6 +282,7 @@ impl MacTextSystemState { let name = gpui::font_name_with_fallbacks(name, ".AppleSystemUIFont"); let mut font_ids = SmallVec::new(); + let mut postscript_names_seen = HashSet::default(); let family = self .memory_source .select_family_by_name(name) @@ -340,15 +341,38 @@ impl MacTextSystemState { .is_some()) } { log::error!( - "Failed to read traits for font {:?}", - font.postscript_name().unwrap() + "Failed to read traits for font {:?} (PostScript name {:?})", + font.full_name(), + font.postscript_name(), ); continue; } + let Some(postscript_name) = font.postscript_name() else { + log::warn!( + "font {:?} in family {:?} has no PostScript name; skipping", + font.full_name(), + name, + ); + continue; + }; + // Dedup is scoped to this single `load_family` call (issue #55472). + // The same family can be reloaded later under a different `FontKey` + // (different features/fallbacks); a global check against + // `font_ids_by_postscript_name` would skip every already-registered + // font and leave the second call's `font_ids` empty. + if !postscript_names_seen.insert(postscript_name.clone()) { + log::warn!( + "skipping duplicate font {:?} with PostScript name {:?} \ + in family {:?}", + font.full_name(), + postscript_name, + name, + ); + continue; + } let font_id = FontId(self.fonts.len()); font_ids.push(font_id); - let postscript_name = font.postscript_name().unwrap(); self.font_ids_by_postscript_name .insert(postscript_name.clone(), font_id); self.postscript_names_by_font_id From 7194f987a74317881768b4d3a5ce1cc93a3228bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=B7=E6=B8=A1?= Date: Tue, 7 Jul 2026 17:01:58 +0800 Subject: [PATCH 107/197] Fix disabled Windows window controls (#60440) ## Summary - Store Windows window minimizable and resizable capabilities on `WindowsWindowInner`. - Prevent custom titlebar hit testing from returning minimize/maximize button hit targets when the corresponding capability is disabled. - Swallow disabled native non-client minimize/maximize button mouse events so they cannot fall through to the default window procedure or GPUI's manual `ShowWindowAsync` handling. Fixes #52067. ## Validation - `cargo fmt --package gpui_windows` - `cargo check -p gpui_windows` - `git diff --check` Release Notes: - Fixed disabled minimize and maximize window controls still activating on Windows. --- crates/gpui_windows/src/events.rs | 20 +++++++++++++++----- crates/gpui_windows/src/window.rs | 8 ++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index 619765a2fd6..b293763bd9e 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -903,8 +903,12 @@ impl WindowsWindowInner { WindowControlArea::Drag if self.is_movable => Some(HTCAPTION as _), WindowControlArea::Drag => None, WindowControlArea::Close => Some(HTCLOSE as _), - WindowControlArea::Max => Some(HTMAXBUTTON as _), - WindowControlArea::Min => Some(HTMINBUTTON as _), + WindowControlArea::Max if self.is_resizable => Some(HTMAXBUTTON as _), + WindowControlArea::Max if self.is_movable => Some(HTCAPTION as _), + WindowControlArea::Max => Some(HTNOWHERE as _), + WindowControlArea::Min if self.is_minimizable => Some(HTMINBUTTON as _), + WindowControlArea::Min if self.is_movable => Some(HTCAPTION as _), + WindowControlArea::Min => Some(HTNOWHERE as _), }) } else { None @@ -926,7 +930,11 @@ impl WindowsWindowInner { }; unsafe { ScreenToClient(handle, &mut cursor_point).ok().log_err() }; - if !self.state.is_maximized() && 0 <= cursor_point.y && cursor_point.y <= frame_y { + if self.is_resizable + && !self.state.is_maximized() + && 0 <= cursor_point.y + && cursor_point.y <= frame_y + { // x-axis actually goes from -frame_x to 0 return Some(if cursor_point.x <= 0 { HTTOPLEFT @@ -1052,11 +1060,12 @@ impl WindowsWindowInner { && let Some(last_pressed) = last_pressed { let handled = match (wparam.0 as u32, last_pressed) { - (HTMINBUTTON, HTMINBUTTON) => { + (HTMINBUTTON, HTMINBUTTON) if self.is_minimizable => { unsafe { ShowWindowAsync(handle, SW_MINIMIZE).ok().log_err() }; true } - (HTMAXBUTTON, HTMAXBUTTON) => { + (HTMINBUTTON, HTMINBUTTON) => true, + (HTMAXBUTTON, HTMAXBUTTON) if self.is_resizable => { if self.state.is_maximized() { unsafe { ShowWindowAsync(handle, SW_NORMAL).ok().log_err() }; } else { @@ -1064,6 +1073,7 @@ impl WindowsWindowInner { } true } + (HTMAXBUTTON, HTMAXBUTTON) => true, (HTCLOSE, HTCLOSE) => { unsafe { PostMessageW(Some(handle), WM_CLOSE, WPARAM::default(), LPARAM::default()) diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index c9352a60035..547ee7a9dde 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -94,6 +94,8 @@ pub(crate) struct WindowsWindowInner { pub(crate) handle: AnyWindowHandle, pub(crate) hide_title_bar: bool, pub(crate) is_movable: bool, + pub(crate) is_resizable: bool, + pub(crate) is_minimizable: bool, pub(crate) executor: ForegroundExecutor, pub(crate) validation_number: usize, pub(crate) main_receiver: PriorityQueueReceiver, @@ -262,6 +264,8 @@ impl WindowsWindowInner { handle: context.handle, hide_title_bar: context.hide_title_bar, is_movable: context.is_movable, + is_resizable: context.is_resizable, + is_minimizable: context.is_minimizable, executor: context.executor.clone(), validation_number: context.validation_number, main_receiver: context.main_receiver.clone(), @@ -384,6 +388,8 @@ struct WindowCreateContext { hide_title_bar: bool, display: WindowsDisplay, is_movable: bool, + is_resizable: bool, + is_minimizable: bool, min_size: Option>, executor: ForegroundExecutor, current_cursor: Option, @@ -487,6 +493,8 @@ impl WindowsWindow { hide_title_bar, display, is_movable: params.is_movable, + is_resizable: params.is_resizable, + is_minimizable: params.is_minimizable, min_size: params.window_min_size, executor, current_cursor, From 71b3b9b8874218170de42cc98f96816b683b2005 Mon Sep 17 00:00:00 2001 From: Lysastriel <99468824+KiraKiraAyu@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:02:29 +0800 Subject: [PATCH 108/197] gpui_macos: Guard start_display_link against nil screens (#60419) # Objective Prevent a macOS crash in GPUI when AppKit temporarily reports that a visible window has no associated `NSScreen` during display reconfiguration. The crash can happen on this path: ```text NSScreen::deviceDescription gpui_macos::window::display_id_for_screen gpui_macos::window::MacWindowState::start_display_link gpui_macos::window::window_did_change_screen ``` `start_display_link` checked the window occlusion state before creating a display link, but it still assumed `NSWindow.screen()` was non-null. During display changes, sleep/wake, lid close/open, or monitor reconfiguration, AppKit can transiently return `nil` for `screen`, and passing that into `NSScreen::deviceDescription` aborts with a null pointer dereference. I observed this on macOS 26.5 after the system entered a rare display state: once in that state, running GPUI applications would crash immediately after wake. I have not identified the exact OS/display condition that triggers it, so the full wake/reconfiguration scenario is not deterministic, but the crash report consistently points to `NSWindow.screen()` being `nil` when `start_display_link` tries to create a display link. ## Solution Make `display_id_for_screen` explicitly handle a null `NSScreen` by returning `None`. Callers now handle that case by either: - returning early from `start_display_link`, because there is no valid display id to create a display link for yet - skipping a null screen while iterating `NSScreen::screens` The normal non-null screen path is unchanged. This keeps the nil-screen guard at the FFI boundary where `NSScreen::deviceDescription` is called. ## Testing Tested on macOS 26.5. Commands run: ```bash cargo fmt --check -p gpui_macos cargo test -p gpui_macos display_id_for_screen_returns_none_for_null_screen cargo check -p gpui_macos ``` Added a unit test covering the new boundary behavior: `window::tests::display_id_for_screen_returns_none_for_null_screen` ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - Fixed a macOS crash that could occur when display configuration changes while a GPUI window is temporarily not associated with a screen. --- crates/gpui_macos/src/window.rs | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 938633d4749..d3d87608253 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -655,7 +655,10 @@ impl MacWindowState { return; } } - let display_id = unsafe { display_id_for_screen(self.native_window.screen()) }; + let Some(display_id) = display_id_for_screen(unsafe { self.native_window.screen() }) else { + // AppKit can temporarily report no screen while displays are being reconfigured. + return; + }; if let Some(mut display_link) = DisplayLink::new(display_id, self.native_view.as_ptr() as *mut c_void, step).log_err() { @@ -812,8 +815,10 @@ impl MacWindow { let count: u64 = cocoa::foundation::NSArray::count(screens); for i in 0..count { let screen = cocoa::foundation::NSArray::objectAtIndex(screens, i); + let Some(display_id) = display_id_for_screen(screen) else { + continue; + }; let frame = NSScreen::frame(screen); - let display_id = display_id_for_screen(screen); if display_id == display.0 { screen_frame = Some(frame); target_screen = screen; @@ -2995,13 +3000,17 @@ where } } -unsafe fn display_id_for_screen(screen: id) -> CGDirectDisplayID { +fn display_id_for_screen(screen: id) -> Option { + if screen.is_null() { + return None; + } + unsafe { let device_description = NSScreen::deviceDescription(screen); let screen_number_key: id = ns_string("NSScreenNumber"); let screen_number = device_description.objectForKey_(screen_number_key); let screen_number: NSUInteger = msg_send![screen_number, unsignedIntegerValue]; - screen_number as CGDirectDisplayID + Some(screen_number as CGDirectDisplayID) } } @@ -3153,3 +3162,13 @@ extern "C" fn toggle_tab_bar(this: &Object, _sel: Sel, _id: id) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_id_for_screen_returns_none_for_null_screen() { + assert_eq!(display_id_for_screen(nil), None); + } +} From 811fe501a77ffb82c1c1b3d9934ec4e416f853e0 Mon Sep 17 00:00:00 2001 From: Shreyash Saitwal <40118018+shreyashsaitwal@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:59:38 +0530 Subject: [PATCH 109/197] workspace: Fix missing icons on non-terminal tabs during drag (#53637) Previously, only terminal tabs displayed their icon while being dragged, all other tab types showed just the label, making the drag preview inconsistent with the actual tab appearance. https://github.com/user-attachments/assets/7c055945-79d6-4f6e-8969-279a4fb1ff58 This PR fixes that by rendering each tab's icon alongside its label, so the dragged preview now accurately reflects the tab regardless of type. https://github.com/user-attachments/assets/3ad5a99b-5ba7-41d2-a6b3-51101b1f9650 Release Notes: - Fixed missing icons on non-terminal tabs when dragging --------- Co-authored-by: Smit Barmase --- crates/workspace/src/pane.rs | 110 ++++++++++++++++++----------------- 1 file changed, 57 insertions(+), 53 deletions(-) diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index dc3dcd68b8d..47ae6942010 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -2774,6 +2774,54 @@ impl Pane { self.activate_item(index, true, true, window, cx); } + fn tab_icon_element( + &self, + item: &dyn ItemHandle, + is_active: bool, + window: &Window, + cx: &App, + ) -> Option { + let icon = item + .tab_icon(window, cx)? + .size(IconSize::Small) + .color(Color::Muted); + + let item_diagnostic = item + .project_path(cx) + .and_then(|project_path| self.diagnostics.get(&project_path)); + + let Some(diagnostic) = item_diagnostic else { + return Some(icon.into_any_element()); + }; + + let knockout_item_color = if is_active { + cx.theme().colors().tab_active_background + } else { + cx.theme().colors().tab_bar_background + }; + + let (icon_decoration, icon_color) = if matches!(diagnostic, &DiagnosticSeverity::ERROR) { + (IconDecorationKind::X, Color::Error) + } else { + (IconDecorationKind::Triangle, Color::Warning) + }; + + Some( + DecoratedIcon::new( + icon, + Some( + IconDecoration::new(icon_decoration, knockout_item_color, cx) + .color(icon_color.color(cx)) + .position(Point { + x: px(-2.), + y: px(-2.), + }), + ), + ) + .into_any_element(), + ) + } + fn render_tab( &self, ix: usize, @@ -2802,54 +2850,7 @@ impl Pane { cx, ); - let item_diagnostic = item - .project_path(cx) - .map_or(None, |project_path| self.diagnostics.get(&project_path)); - - let decorated_icon = item_diagnostic.map_or(None, |diagnostic| { - let icon = match item.tab_icon(window, cx) { - Some(icon) => icon, - None => return None, - }; - - let knockout_item_color = if is_active { - cx.theme().colors().tab_active_background - } else { - cx.theme().colors().tab_bar_background - }; - - let (icon_decoration, icon_color) = if matches!(diagnostic, &DiagnosticSeverity::ERROR) - { - (IconDecorationKind::X, Color::Error) - } else { - (IconDecorationKind::Triangle, Color::Warning) - }; - - Some(DecoratedIcon::new( - icon.size(IconSize::Small).color(Color::Muted), - Some( - IconDecoration::new(icon_decoration, knockout_item_color, cx) - .color(icon_color.color(cx)) - .position(Point { - x: px(-2.), - y: px(-2.), - }), - ), - )) - }); - - let icon = if decorated_icon.is_none() { - match item_diagnostic { - Some(&DiagnosticSeverity::ERROR) => None, - Some(&DiagnosticSeverity::WARNING) => None, - _ => item - .tab_icon(window, cx) - .map(|icon| icon.color(Color::Muted)), - } - .map(|icon| icon.size(IconSize::Small)) - } else { - None - }; + let icon = self.tab_icon_element(item, is_active, window, cx); let settings = ItemSettings::get_global(cx); let close_side = &settings.close_position; @@ -2888,7 +2889,7 @@ impl Pane { })) }; - let has_file_icon = icon.is_some() | decorated_icon.is_some(); + let has_file_icon = icon.is_some(); let capability = item.capability(cx); let tab = Tab::new(ix) @@ -3040,10 +3041,8 @@ impl Pane { h_flex() .id(("pane-tab-content", ix)) .gap_1() - .children(if let Some(decorated_icon) = decorated_icon { - Some(decorated_icon.into_any_element()) - } else if let Some(icon) = icon { - Some(icon.into_any_element()) + .children(if let Some(icon) = icon { + Some(icon) } else if !capability.editable() { Some(read_only_toggle(capability == Capability::Read).into_any_element()) } else { @@ -4941,8 +4940,13 @@ impl Render for DraggedTab { window, cx, ); + let icon = + self.pane + .read(cx) + .tab_icon_element(self.item.as_ref(), self.is_active, window, cx); Tab::new("") .toggle_state(self.is_active) + .children(icon) .child(label) .render(window, cx) .font(ui_font) From 64c55b038f1e676c11bc4a2fdfbc8117f03144ca Mon Sep 17 00:00:00 2001 From: excited gui <170822527+alan-codes-things@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:53:16 +0100 Subject: [PATCH 110/197] Change OpenAI compatible URL placeholder in edit prediction settings (#58771) Fixed my PR #58753. Change the url placeholder from http://localhost:11434 to http://localhost:8080/v1/completions to match the URL endpoint in the [docs](https://zed.dev/docs/ai/edit-prediction) ```json { "edit_predictions": { "provider": "open_ai_compatible_api", "open_ai_compatible_api": { "api_url": "http://localhost:8080/v1/completions", "model": "deepseek-coder-6.7b-base", "prompt_format": "deepseek_coder", "max_output_tokens": 512 } } } ``` Note: http://localhost:8080/v1/completions/ with an extra / does not work. Added the constants OPEN_AI_COMPATIBLE_API_URL_PLACEHOLDER and OPEN_AI_COMPATIBLE_MODEL_PLACEHOLDER. ### Initial Issue I noticed that using http://localhost:8080 doesn't work with llama.cpp. Giving errors like this from (zed: open log): ``` 2026-06-06T17:55:39+01:00 ERROR [crates/edit_prediction/src/edit_prediction.rs:2464] custom server error: 404 Not Found - {"error":{"message":"File Not Found","type":"not_found_error","code":404}} ``` After reading the docs above, I found out that I had to add /v1/completions to the end. 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 --- .../src/pages/edit_prediction_provider_setup.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs index 3f756a876a8..01550cd16ea 100644 --- a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs +++ b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs @@ -15,6 +15,9 @@ use workspace::AppState; const OLLAMA_API_URL_PLACEHOLDER: &str = "http://localhost:11434"; const OLLAMA_MODEL_PLACEHOLDER: &str = "qwen2.5-coder:3b-base"; +const OPEN_AI_COMPATIBLE_API_URL_PLACEHOLDER: &str = "http://localhost:8080/v1/completions"; +const OPEN_AI_COMPATIBLE_MODEL_PLACEHOLDER: &str = "qwen2.5-coder:3b-base"; + use crate::{ SettingField, SettingItem, SettingsFieldMetadata, SettingsPageItem, SettingsWindow, USER, components::{SettingsInputField, SettingsSectionHeader}, @@ -526,7 +529,7 @@ fn open_ai_compatible_settings() -> Box<[SettingsPageItem]> { json_path: Some("edit_predictions.open_ai_compatible_api.api_url"), }), metadata: Some(Box::new(SettingsFieldMetadata { - placeholder: Some(OLLAMA_API_URL_PLACEHOLDER), + placeholder: Some(OPEN_AI_COMPATIBLE_API_URL_PLACEHOLDER), ..Default::default() })), files: USER, @@ -560,7 +563,7 @@ fn open_ai_compatible_settings() -> Box<[SettingsPageItem]> { json_path: Some("edit_predictions.open_ai_compatible_api.model"), }), metadata: Some(Box::new(SettingsFieldMetadata { - placeholder: Some(OLLAMA_MODEL_PLACEHOLDER), + placeholder: Some(OPEN_AI_COMPATIBLE_MODEL_PLACEHOLDER), ..Default::default() })), files: USER, From 452e1cb27365fbaa935eba1b2aca98279350cbaa Mon Sep 17 00:00:00 2001 From: Vlad Ionescu Date: Tue, 7 Jul 2026 13:16:42 +0300 Subject: [PATCH 111/197] opencode: Model updates + fixes (#60526) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **TL;DR**: model updates + reasoning levels + fixes discovered when working on https://github.com/zed-industries/zed/pull/60373 # Objective Since the model auto-discovery PR was [cancelled](https://github.com/zed-industries/zed/pull/60373#issuecomment-4886521448), here is a manual model list update! I also copied the stand-alone bugfixes/enhancements from that PR. ## Solution A lot of manual work 😅 **OpenCode Zen**: - added Fable 5 and Sonnet 5 - added models that were previously only available on OpenCode Go: GLM 5.2, Kimi K2.7 Code, and Minimax M3 - added reasoning levels for all models. I started from the data on [`Models.dev`](https://models.dev) (the `/api.json` raw data), and then I matched that with what is shown in the OpenCode CLI and what I know to be true **OpenCode Go**: - added reasoning levels for GLM 5.2 **OpenCode in general**: - added `protocol` validation for the settings, by moving from a random `String` to an `enum`, for both nicer error messages (random strings or typos will get an error instead of using `openai_chat` by default) and to avoid issues like [folks saying non-existent protocols are a thing](https://github.com/zed-industries/zed/issues/56869#issuecomment-4550154554) - enabled parallel tool calls by default. As per [OpenCode developer on Discord](https://discord.com/channels/1391832426048651334/1471233160993050918/1472020924881702912), _"almost all models worth using support parallel tool calling natively"_. Manual tests confirmed all OpenCode Go models support this correctly (and was enabled by default on the OpenCode side for all but 1 model). I initially wanted to skip this from the release notes, but I added it so folks are aware of it in case any issues are caused by this being enabled for all models - allegedly fixed Google thinking since reasoning levels / thinking effort levels were added for Google models and an auto-checker LLM highlighted that was not properly configured - added support for the new-ish `supports_disabling_thinking` so thinking-only models don't get a no-impact toggle to disable thinking I have no idea if any of the Free models will disappear in 2 days or not, so I did not update those 🤷 (as per decision in https://github.com/zed-industries/zed/issues/56869#issuecomment-4466637648) ## Testing The Zen and Google changes were not tested as I don't have a Zen subscription and I stubbornly refuse to get one. The Free&Go changes were tested by running a "_rename this variable for me. add a function. delete the function_" test with a few different models. ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - Agent: OpenCode settings now validate `protocol` values - Agent: OpenCode only shows the "Disable thinking" toggle if thinking can indeed be disabled/enabled - Agent: OpenCode models now enable parallel tool calls by default - Agent: Updated OpenCode Zen models (added Fable 5, Sonnet 5, GLM 5.2, Kimi K2.7 Code, and Minimax M3) - Agent: Added OpenCode Go GLM 5.2 reasoning effort levels - Agent: Added reasoning effort levels for all OpenCode Zen models - Agent: Fixed thinking for OpenCode Zen Google models --- .../language_models/src/provider/opencode.rs | 36 ++-- crates/opencode/src/opencode.rs | 184 +++++++++++++++--- crates/settings_content/src/language_model.rs | 16 +- docs/src/ai/use-api-access.md | 4 +- 4 files changed, 191 insertions(+), 49 deletions(-) diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index 94689cc0d66..c3e5c0f8dab 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -14,6 +14,7 @@ use language_model::{ SubPageProviderSettings, env_var, }; use opencode::{ApiProtocol, OPENCODE_API_URL, OpenCodeSubscription}; +pub use settings::OpenCodeApiProtocol; pub use settings::OpenCodeAvailableModel as AvailableModel; use settings::{Settings, SettingsStore, update_settings_file}; use std::sync::{Arc, LazyLock}; @@ -263,12 +264,12 @@ impl LanguageModelProvider for OpenCodeLanguageModelProvider { } for model in &settings.available_models { - let protocol = match model.protocol.as_str() { - "anthropic" => ApiProtocol::Anthropic, - "openai_responses" => ApiProtocol::OpenAiResponses, - "openai_chat" => ApiProtocol::OpenAiChat, - "google" => ApiProtocol::Google, - _ => ApiProtocol::OpenAiChat, // default fallback + let protocol = match model.protocol { + Some(OpenCodeApiProtocol::Anthropic) => ApiProtocol::Anthropic, + Some(OpenCodeApiProtocol::OpenAiResponses) => ApiProtocol::OpenAiResponses, + Some(OpenCodeApiProtocol::OpenAiChat) => ApiProtocol::OpenAiChat, + Some(OpenCodeApiProtocol::Google) => ApiProtocol::Google, + None => ApiProtocol::OpenAiChat, // default fallback }; let subscription = match model.subscription { Some(settings::OpenCodeModelSubscription::Go) => OpenCodeSubscription::Go, @@ -574,6 +575,12 @@ impl LanguageModel for OpenCodeLanguageModel { .is_some_and(|levels| levels.iter().any(|effort| *effort != ReasoningEffort::None)) } + fn supports_disabling_thinking(&self) -> bool { + self.model + .supported_reasoning_effort_levels() + .is_some_and(|levels| levels.contains(&ReasoningEffort::None)) + } + fn supported_effort_levels(&self) -> Vec { self.model .supported_reasoning_effort_levels() @@ -692,7 +699,7 @@ impl LanguageModel for OpenCodeLanguageModel { let openai_request = into_open_ai( request, self.model.id(), - false, + true, false, self.model.max_output_tokens(self.subscription), ChatCompletionMaxTokensParameter::MaxCompletionTokens, @@ -715,7 +722,7 @@ impl LanguageModel for OpenCodeLanguageModel { let response_request = into_open_ai_response( request, self.model.id(), - false, + true, false, self.model.max_output_tokens(self.subscription), None, @@ -730,11 +737,14 @@ impl LanguageModel for OpenCodeLanguageModel { .boxed() } ApiProtocol::Google => { - let google_request = into_google( - request, - self.model.id().to_string(), - google_ai::GoogleModelMode::Default, - ); + let mode = if self.supports_thinking() && request.thinking_allowed { + google_ai::GoogleModelMode::Thinking { + budget_tokens: None, + } + } else { + google_ai::GoogleModelMode::Default + }; + let google_request = into_google(request, self.model.id().to_string(), mode); let stream = self.stream_google(google_request, http_client, extra_headers, cx); async move { let mapper = GoogleEventMapper::new(); diff --git a/crates/opencode/src/opencode.rs b/crates/opencode/src/opencode.rs index ab030721e35..79d95b9cd25 100644 --- a/crates/opencode/src/opencode.rs +++ b/crates/opencode/src/opencode.rs @@ -77,6 +77,10 @@ pub enum Model { ClaudeSonnet4, #[serde(rename = "claude-haiku-4-5")] ClaudeHaiku4_5, + #[serde(rename = "claude-sonnet-5")] + ClaudeSonnet5, + #[serde(rename = "claude-fable-5")] + ClaudeFable5, // -- OpenAI Responses API models -- #[serde(rename = "gpt-5.5")] @@ -203,23 +207,24 @@ impl Model { match self { // Models available in both Zen and Go Self::Glm5_1 + | Self::Glm5_2 | Self::KimiK2_6 + | Self::KimiK2_7Code | Self::MiniMaxM2_7 + | Self::MiniMaxM3 | Self::DeepSeekV4Pro | Self::DeepSeekV4Flash | Self::Qwen3_6Plus => &[OpenCodeSubscription::Zen, OpenCodeSubscription::Go], // Go-only models - Self::MimoV2_5Pro - | Self::MimoV2_5 - | Self::Qwen3_7Plus - | Self::Qwen3_7Max - | Self::KimiK2_7Code - | Self::Glm5_2 - | Self::MiniMaxM3 => &[OpenCodeSubscription::Go], + Self::MimoV2_5Pro | Self::MimoV2_5 | Self::Qwen3_7Plus | Self::Qwen3_7Max => { + &[OpenCodeSubscription::Go] + } // Deprecated on Go (per models.dev); still offered on Zen - Self::Glm5 | Self::KimiK2_5 | Self::MiniMaxM2_5 => &[OpenCodeSubscription::Zen], + Self::Glm5 | Self::KimiK2_5 | Self::MiniMaxM2_5 | Self::Qwen3_5Plus => { + &[OpenCodeSubscription::Zen] + } // Free models Self::Nemotron3UltraFree | Self::BigPickle => &[OpenCodeSubscription::Free], @@ -243,6 +248,8 @@ impl Model { Self::ClaudeSonnet4_5 => "claude-sonnet-4-5", Self::ClaudeSonnet4 => "claude-sonnet-4", Self::ClaudeHaiku4_5 => "claude-haiku-4-5", + Self::ClaudeSonnet5 => "claude-sonnet-5", + Self::ClaudeFable5 => "claude-fable-5", Self::Gpt5_5 => "gpt-5.5", Self::Gpt5_5Pro => "gpt-5.5-pro", @@ -302,6 +309,8 @@ impl Model { Self::ClaudeSonnet4_5 => "Claude Sonnet 4.5", Self::ClaudeSonnet4 => "Claude Sonnet 4", Self::ClaudeHaiku4_5 => "Claude Haiku 4.5", + Self::ClaudeSonnet5 => "Claude Sonnet 5", + Self::ClaudeFable5 => "Claude Fable 5", Self::Gpt5_5 => "GPT 5.5", Self::Gpt5_5Pro => "GPT 5.5 Pro", @@ -356,7 +365,7 @@ impl Model { match self { // Models offered by OpenCode have the same configuration across subscriptions // with one outlier: non-free MiniMax models - Self::MiniMaxM2_7 | Self::MiniMaxM2_5 => { + Self::MiniMaxM3 | Self::MiniMaxM2_7 | Self::MiniMaxM2_5 => { if subscription == OpenCodeSubscription::Zen { ApiProtocol::OpenAiChat } else { @@ -364,11 +373,13 @@ impl Model { } } - Self::ClaudeOpus4_8 + Self::ClaudeFable5 + | Self::ClaudeOpus4_8 | Self::ClaudeOpus4_7 | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_5 | Self::ClaudeOpus4_1 + | Self::ClaudeSonnet5 | Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4 @@ -394,9 +405,9 @@ impl Model { Self::Gemini3_1Pro | Self::Gemini3Flash | Self::Gemini3_5Flash => ApiProtocol::Google, - Self::Qwen3_7Max | Self::Qwen3_7Plus => ApiProtocol::Anthropic, - - Self::MiniMaxM3 => ApiProtocol::Anthropic, + Self::Qwen3_7Max | Self::Qwen3_7Plus | Self::Qwen3_6Plus | Self::Qwen3_5Plus => { + ApiProtocol::Anthropic + } Self::Glm5 | Self::Glm5_1 @@ -407,8 +418,6 @@ impl Model { | Self::KimiK2_7Code | Self::MimoV2_5Pro | Self::MimoV2_5 - | Self::Qwen3_5Plus - | Self::Qwen3_6Plus | Self::DeepSeekV4Pro | Self::DeepSeekV4Flash | Self::BigPickle @@ -432,6 +441,7 @@ impl Model { | Self::Glm5_2 | Self::MiniMaxM2_5 | Self::MiniMaxM2_7 + | Self::MiniMaxM3 | Self::Nemotron3UltraFree | Self::BigPickle => true, @@ -453,6 +463,8 @@ impl Model { Self::ClaudeOpus4_5 | Self::ClaudeHaiku4_5 => 200_000, Self::ClaudeOpus4_1 => 200_000, Self::ClaudeSonnet4 => 1_000_000, + Self::ClaudeSonnet5 => 1_000_000, + Self::ClaudeFable5 => 1_000_000, // OpenAI models Self::Gpt5_5 | Self::Gpt5_5Pro => 1_050_000, @@ -473,7 +485,13 @@ impl Model { // OpenAI-compatible models Self::MiniMaxM2_7 => 204_800, - Self::MiniMaxM3 => 512_000, + Self::MiniMaxM3 => { + if subscription == OpenCodeSubscription::Go { + 1_000_000 + } else { + 512_000 + } + } Self::MiniMaxM2_5 => 204_800, Self::Glm5 | Self::Glm5_1 => { if subscription == OpenCodeSubscription::Go { @@ -514,6 +532,8 @@ impl Model { | Self::ClaudeHaiku4_5 | Self::ClaudeSonnet4 => Some(64_000), Self::ClaudeOpus4_1 => Some(32_000), + Self::ClaudeSonnet5 => Some(128_000), + Self::ClaudeFable5 => Some(128_000), // OpenAI models Self::Gpt5_5 @@ -539,7 +559,13 @@ impl Model { // OpenAI-compatible models Self::MiniMaxM2_7 => Some(131_072), - Self::MiniMaxM3 => Some(131_072), + Self::MiniMaxM3 => { + if subscription == OpenCodeSubscription::Go { + Some(131_072) + } else { + Some(128_000) + } + } Self::MiniMaxM2_5 => { if subscription == OpenCodeSubscription::Go { Some(65_536) @@ -587,7 +613,9 @@ impl Model { | Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4 - | Self::ClaudeHaiku4_5 => true, + | Self::ClaudeHaiku4_5 + | Self::ClaudeSonnet5 + | Self::ClaudeFable5 => true, // OpenAI models support images Self::Gpt5_5 @@ -649,20 +677,11 @@ impl Model { pub fn supported_reasoning_effort_levels(&self) -> Option> { match self { - Self::ClaudeOpus4_8 => Some(vec![ - ReasoningEffort::Low, - ReasoningEffort::Medium, - ReasoningEffort::High, - ReasoningEffort::XHigh, - ]), - - Self::Nemotron3UltraFree | Self::MimoV2_5Pro | Self::MimoV2_5 => Some(vec![ - ReasoningEffort::Low, - ReasoningEffort::Medium, - ReasoningEffort::High, - ]), - - Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => Some(vec![ + // Anthropic models + Self::ClaudeFable5 + | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeSonnet5 => Some(vec![ ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High, @@ -670,6 +689,107 @@ impl Model { ReasoningEffort::Max, ]), + Self::ClaudeOpus4_6 | Self::ClaudeSonnet4_6 => Some(vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::Max, + ]), + + Self::ClaudeOpus4_5 => Some(vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]), + + // OpenAI models + Self::Gpt5_5 + | Self::Gpt5_4 + | Self::Gpt5_4Mini + | Self::Gpt5_4Nano + | Self::Gpt5_3Codex + | Self::Gpt5_2 => Some(vec![ + ReasoningEffort::None, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ]), + + Self::Gpt5_5Pro | Self::Gpt5_4Pro => Some(vec![ + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ]), + + Self::Gpt5_2Codex | Self::Gpt5_3Spark | Self::Gpt5_1CodexMax => Some(vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ]), + + Self::Gpt5_1 => Some(vec![ + ReasoningEffort::None, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]), + + Self::Gpt5Codex | Self::Gpt5_1Codex | Self::Gpt5_1CodexMini => Some(vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]), + + Self::Gpt5 | Self::Gpt5Nano => Some(vec![ + ReasoningEffort::Minimal, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]), + + // Google models + Self::Gemini3Flash | Self::Gemini3_5Flash => Some(vec![ + ReasoningEffort::Minimal, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]), + + Self::Gemini3_1Pro => Some(vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]), + + // DeepSeek models + Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => Some(vec![ + // OpenCode also supports Low&Medium but as per DeepSeek those are mapped to High + ReasoningEffort::High, + ReasoningEffort::Max, + ]), + + // MiniMax models + Self::MiniMaxM3 => Some(vec![ReasoningEffort::None]), + + // NVIDIA models + Self::Nemotron3UltraFree => Some(vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]), + + // Xiaomi MiMo models + Self::MimoV2_5Pro | Self::MimoV2_5 => Some(vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]), + + // Z AI models + Self::Glm5_2 => Some(vec![ReasoningEffort::High, ReasoningEffort::Max]), + Self::Custom { reasoning_effort_levels, .. diff --git a/crates/settings_content/src/language_model.rs b/crates/settings_content/src/language_model.rs index e58f3dcc02b..f61d80ead75 100644 --- a/crates/settings_content/src/language_model.rs +++ b/crates/settings_content/src/language_model.rs @@ -217,6 +217,18 @@ pub struct OpenCodeSettingsContent { pub show_free_models: Option, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom)] +pub enum OpenCodeApiProtocol { + #[serde(rename = "anthropic")] + Anthropic, + #[serde(rename = "openai_responses", alias = "open_ai_responses")] + OpenAiResponses, + #[serde(rename = "openai_chat", alias = "open_ai_chat")] + OpenAiChat, + #[serde(rename = "google")] + Google, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom)] #[serde(rename_all = "snake_case")] pub enum OpenCodeModelSubscription { @@ -232,8 +244,8 @@ pub struct OpenCodeAvailableModel { pub display_name: Option, pub max_tokens: u64, pub max_output_tokens: Option, - /// The API protocol to use for this model: "anthropic", "openai_responses", "openai_chat", or "google". - pub protocol: String, + /// The API protocol to use for this model: "anthropic", "openai_responses", "openai_chat", or "google". Defaults to "openai_chat". + pub protocol: Option, /// The subscription for this model: "zen", "go", or "free". Defaults to Zen. pub subscription: Option, /// Custom Model API URL to use for this model. diff --git a/docs/src/ai/use-api-access.md b/docs/src/ai/use-api-access.md index 0ddf85ba33b..7af0c0e68a3 100644 --- a/docs/src/ai/use-api-access.md +++ b/docs/src/ai/use-api-access.md @@ -406,8 +406,8 @@ The available configuration options for custom OpenCode models are: - `display_name` (optional): human-readable model name shown in the UI, such as `Custom GLM 9000` - `max_tokens` (required): maximum model context window size, such as `1000000` - `max_output_tokens` (optional): maximum tokens the model can generate, such as `64000` -- `protocol` (required): model API protocol, one of `"anthropic"`, `"openai_responses"`, `"openai_chat"`, or `"google"` -- `reasoning_effort_levels` (optional): list of supported reasoning effort levels, such as `["low", "medium", "high", "max"]`. The last value in the list is used as the default +- `protocol` (optional, default `"openai_chat"`): model API protocol, one of `"anthropic"`, `"openai_responses"`, `"openai_chat"`, or `"google"` +- `reasoning_effort_levels` (optional): list of supported reasoning effort levels, such as `["none", "low", "medium", "high", "xhigh", "max"]`. The last value in the list is used as the default - `interleaved_reasoning` (optional, default `false`): whether thinking tokens are sent as a dedicated `reasoning_content` field. Applies only when using the `openai_chat` protocol - `subscription` (optional): `"zen"`, `"go"`, or `"free"`; defaults to `"zen"` - `custom_model_api_url` (optional): custom API base URL to use instead of the default OpenCode API From 2eeca73e665a814c5b1430a3bfd8aacff0477fb3 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Tue, 7 Jul 2026 12:23:28 +0200 Subject: [PATCH 112/197] auto_update: Fix installer temp dirs leaking on macOS (#60528) The unmount of the update disk image was made async-and-detached in #38867, which introduced a race: the installer TempDir was dropped (running remove_dir_all) while the DMG was still mounted inside it. The removal failed silently, leaking a zed-auto-update* dir containing the ~140 MB DMG in /private/var/folders on every update. Now the unmount is awaited before the temp dir is dropped (installation already runs on the background executor since #58767, so this no longer blocks the UI), with the Drop impl kept as a safety net for early exits and cancellation. Additionally, stale installer dirs older than 24 hours are swept from the temp dir when update polling starts, so existing accumulated leaks get reclaimed. Closes FR-104 Closes #58835 Release Notes: - Fixed the macOS auto-updater leaking a copy of the downloaded update in the system temp directory on every update, and added cleanup of previously leaked files. --- crates/auto_update/src/auto_update.rs | 128 ++++++++++++++++++++------ 1 file changed, 100 insertions(+), 28 deletions(-) diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index 67643ee6345..4193e8185cd 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -194,35 +194,53 @@ struct MacOsUnmounter<'a> { background_executor: &'a BackgroundExecutor, } +impl MacOsUnmounter<'_> { + /// Unmounts the disk image and waits for completion. This must happen + /// before the `InstallerDir` is dropped: deleting the temp dir while the + /// image is still mounted inside it fails silently and leaks the + /// directory (and the downloaded DMG) in the system temp dir. + async fn unmount(mut self) { + let mount_path = mem::take(&mut self.mount_path); + unmount_disk_image(&mount_path).await; + } +} + impl Drop for MacOsUnmounter<'_> { fn drop(&mut self) { let mount_path = mem::take(&mut self.mount_path); + // Safety net for early exits and cancellation; the happy path calls + // `unmount`, which leaves the path empty. + if mount_path.as_os_str().is_empty() { + return; + } self.background_executor - .spawn(async move { - let unmount_output = new_command("hdiutil") - .args(["detach", "-force"]) - .arg(&mount_path) - .output() - .await; - match unmount_output { - Ok(output) if output.status.success() => { - log::info!("Successfully unmounted the disk image"); - } - Ok(output) => { - log::error!( - "Failed to unmount disk image: {:?}", - String::from_utf8_lossy(&output.stderr) - ); - } - Err(error) => { - log::error!("Error while trying to unmount disk image: {:?}", error); - } - } - }) + .spawn(async move { unmount_disk_image(&mount_path).await }) .detach(); } } +async fn unmount_disk_image(mount_path: &Path) { + let unmount_output = new_command("hdiutil") + .args(["detach", "-force"]) + .arg(mount_path) + .output() + .await; + match unmount_output { + Ok(output) if output.status.success() => { + log::info!("Successfully unmounted the disk image"); + } + Ok(output) => { + log::error!( + "Failed to unmount disk image: {:?}", + String::from_utf8_lossy(&output.stderr) + ); + } + Err(error) => { + log::error!("Error while trying to unmount disk image: {:?}", error); + } + } +} + #[derive(Clone, Copy, Debug, RegisterSetting)] struct AutoUpdateSetting(bool); @@ -345,6 +363,9 @@ pub fn view_release_notes(_: &ViewReleaseNotes, cx: &mut App) -> Option<()> { None } +#[cfg(not(target_os = "windows"))] +const INSTALLER_DIR_PREFIX: &str = "zed-auto-update"; + #[cfg(not(target_os = "windows"))] struct InstallerDir(tempfile::TempDir); @@ -353,7 +374,7 @@ impl InstallerDir { async fn new() -> Result { Ok(Self( tempfile::Builder::new() - .prefix("zed-auto-update") + .prefix(INSTALLER_DIR_PREFIX) .tempdir()?, )) } @@ -448,6 +469,9 @@ impl AutoUpdater { .log_err(); } + #[cfg(all(not(target_os = "windows"), not(test)))] + cx.background_spawn(cleanup_stale_installer_dirs()).detach(); + loop { this.update(cx, |this, cx| this.poll(UpdateCheckType::Automatic, cx))?; cx.background_executor().timer(poll_interval).await; @@ -1161,8 +1185,7 @@ async fn install_release_macos( String::from_utf8_lossy(&output.stderr) ); - // Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits - let _unmounter = MacOsUnmounter { + let unmounter = MacOsUnmounter { mount_path: mount_path.clone(), background_executor, }; @@ -1171,10 +1194,13 @@ async fn install_release_macos( cmd.args(["-av", "--delete", "--exclude", "Icon?"]) .arg(&mounted_app_path) .arg(&running_app_path); - let output = cmd - .output() - .await - .with_context(|| "failed to rsync: {cmd}")?; + let rsync_output = cmd.output().await; + + // Await the unmount (even if rsync failed) so that the installer temp dir + // can be deleted once this function returns. + unmounter.unmount().await; + + let output = rsync_output.with_context(|| "failed to rsync: {cmd}")?; anyhow::ensure!( output.status.success(), @@ -1185,6 +1211,52 @@ async fn install_release_macos( Ok(None) } +/// Removes stale installer dirs from the system temp dir. Older Zed versions +/// leaked one per update by deleting the dir while the downloaded disk image +/// was still mounted inside it, which made the deletion fail silently. +#[cfg(any(rust_analyzer, all(not(target_os = "windows"), not(test))))] +async fn cleanup_stale_installer_dirs() { + const STALE_INSTALLER_DIR_AGE: Duration = Duration::from_secs(24 * 60 * 60); + + let temp_dir = std::env::temp_dir(); + let Ok(mut entries) = fs::read_dir(&temp_dir).await else { + log::warn!("failed to read temp dir {temp_dir:?} while cleaning up installer dirs"); + return; + }; + while let Some(entry) = entries.next().await { + let Ok(entry) = entry else { + continue; + }; + if !entry + .file_name() + .to_string_lossy() + .starts_with(INSTALLER_DIR_PREFIX) + { + continue; + } + // Leave recent dirs alone, as they may belong to an update currently + // in progress in another Zed instance. + let is_stale = entry.metadata().await.ok().is_some_and(|metadata| { + metadata.is_dir() + && metadata.modified().ok().is_some_and(|modified| { + SystemTime::now() + .duration_since(modified) + .is_ok_and(|age| age > STALE_INSTALLER_DIR_AGE) + }) + }); + if is_stale { + if let Err(error) = fs::remove_dir_all(entry.path()).await { + log::warn!( + "failed to remove stale installer dir {:?}: {error}", + entry.path() + ); + } else { + log::info!("removed stale installer dir {:?}", entry.path()); + } + } + } +} + async fn cleanup_windows() -> Result<()> { let parent = std::env::current_exe()? .parent() From 59c021d8dcee7f5b0ac17d298dce96ca8633421e Mon Sep 17 00:00:00 2001 From: hiro / THETIME Date: Tue, 7 Jul 2026 19:27:57 +0900 Subject: [PATCH 113/197] copilot_ui: Fix Copilot sign-in window focus on Hyprland (#59933) # Objective The Copilot sign-in dialog was created without an `app_id` or window title, resulting in an empty WM class/title on Linux. Tiling window managers with class-based no-focus rules (like Hyprland's default configuration in Omarchy) treat such windows as anonymous popups and refuse to focus them, making the dialog impossible to interact with. ## Solution Set both `app_id` and window title on the Copilot code verification window, following the established pattern used in other UI components like `agent_ui` and `settings_ui`. Added `release_channel` as a dependency to `crates/copilot_ui/Cargo.toml` and called `app_id(ReleaseChannel::app_id(cx))` and `window_title("Use GitHub Copilot in Zed")` in `open_copilot_code_verification_window`. ## Testing Verified on Hyprland (Omarchy) by inspecting window properties with `hyprctl clients`: **Before (empty class/title):** ``` Window 5606ee1dd280 -> : class: title: acceptsInput: 0 ``` **After (with proper class/title):** ``` Window 5606ee22b660 -> Use GitHub Copilot in Zed: class: dev.zed.Zed-Dev title: Use GitHub Copilot in Zed acceptsInput: 1 ``` The dialog now receives keyboard focus and mouse input correctly on Hyprland. I tested on Linux only; this fix lives in the window creation call so it is a no-op on macOS and Windows where `app_id` is ignored. ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - Fixed Copilot sign-in window not being focusable on Hyprland and similar tiling window managers --------- Co-authored-by: Smit Barmase --- Cargo.lock | 1 + crates/copilot_ui/Cargo.toml | 1 + crates/copilot_ui/src/sign_in.rs | 12 ++++++++---- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b1d1e834170..1ada6ef9e5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3907,6 +3907,7 @@ dependencies = [ "lsp", "menu", "project", + "release_channel", "serde_json", "settings", "ui", diff --git a/crates/copilot_ui/Cargo.toml b/crates/copilot_ui/Cargo.toml index 14b9fe43679..ba5935c65ed 100644 --- a/crates/copilot_ui/Cargo.toml +++ b/crates/copilot_ui/Cargo.toml @@ -28,6 +28,7 @@ log.workspace = true lsp.workspace = true menu.workspace = true project.workspace = true +release_channel.workspace = true serde_json.workspace = true settings.workspace = true ui.workspace = true diff --git a/crates/copilot_ui/src/sign_in.rs b/crates/copilot_ui/src/sign_in.rs index 4eb73b6d82e..5741d613485 100644 --- a/crates/copilot_ui/src/sign_in.rs +++ b/crates/copilot_ui/src/sign_in.rs @@ -9,6 +9,7 @@ use gpui::{ Subscription, TaskExt, Window, WindowBounds, WindowOptions, div, point, }; use project::project_settings::ProjectSettings; +use release_channel::ReleaseChannel; use settings::Settings as _; use ui::{ButtonLike, CommonAnimationExt, ConfiguredApiCard, Vector, VectorName, prelude::*}; use util::ResultExt as _; @@ -55,12 +56,13 @@ pub fn reinstall_and_sign_in(copilot: Entity, window: &mut Window, cx: fn open_copilot_code_verification_window(copilot: &Entity, window: &Window, cx: &mut App) { let current_window_center = window.bounds().center(); - let height = px(450.); - let width = px(350.); + let width = px(450.); + let height = px(350.); let window_bounds = WindowBounds::Windowed(gpui::bounds( - current_window_center - point(height / 2.0, width / 2.0), - gpui::size(height, width), + current_window_center - point(width / 2.0, height / 2.0), + gpui::size(width, height), )); + let app_id = ReleaseChannel::global(cx).app_id(); cx.open_window( WindowOptions { kind: gpui::WindowKind::Floating, @@ -68,9 +70,11 @@ fn open_copilot_code_verification_window(copilot: &Entity, window: &Win is_resizable: false, is_movable: true, titlebar: Some(gpui::TitlebarOptions { + title: Some("Use GitHub Copilot in Zed".into()), appears_transparent: true, ..Default::default() }), + app_id: Some(app_id.to_owned()), ..Default::default() }, |window, cx| cx.new(|cx| CopilotCodeVerification::new(&copilot, window, cx)), From 693962917b5a015949ad2e768bc10ea169d41546 Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Tue, 7 Jul 2026 18:39:29 +0800 Subject: [PATCH 114/197] gpui: Fix clear drag overlay when external drag ends outside window (#45759) Release Notes: - Fixed clear drag overlay when external drag ends outside window When dragging files from macOS Finder over the project panel and then dragging back to Finder, the drag overlay remained visible because the drag state was not properly cleaned up. The root cause was that only `draggingExited:` was handled, but not `draggingEnded:`. On macOS: - `draggingExited:` is called when the drag leaves the window area - `draggingEnded:` is called when the drag operation ends entirely When a user drags a file back to Finder and drops it there, `draggingEnded:` is called but was not being handled. --------- Signed-off-by: Xiaobo Liu --- crates/gpui/src/elements/div.rs | 47 ++++++++++++++++++-- crates/project_panel/src/project_panel.rs | 52 ++++++++++++----------- 2 files changed, 71 insertions(+), 28 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 4a61069a0a9..ebbce8f84c9 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -21,10 +21,10 @@ use crate::{ Display, Element, ElementId, Entity, EntityId, FocusHandle, Global, GlobalElementId, Hitbox, HitboxBehavior, HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, KeyboardButton, KeyboardClickEvent, LayoutId, ModifiersChangedEvent, MouseButton, - MouseClickEvent, MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, Overflow, - ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style, - StyleRefinement, Styled, Task, TooltipId, Visibility, Window, WindowControlArea, point, px, - size, + MouseClickEvent, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent, + MouseUpEvent, Overflow, ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, + Size, Style, StyleRefinement, Styled, Task, TooltipId, Visibility, Window, WindowControlArea, + point, px, size, }; use collections::HashMap; use gpui_util::ResultExt; @@ -305,6 +305,22 @@ impl Interactivity { })); } + /// Bind the given callback to the mouse exit event, during the bubble phase. + /// The imperative API equivalent to [`InteractiveElement::on_mouse_exit`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + pub fn on_mouse_exit( + &mut self, + listener: impl Fn(&MouseExitEvent, &mut Window, &mut App) + 'static, + ) { + self.mouse_exit_listeners + .push(Box::new(move |event, phase, hitbox, window, cx| { + if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) { + (listener)(event, window, cx); + } + })); + } + /// Bind the given callback to the mouse drag event of the given type. Note that this /// will be called for all move events, inside or outside of this element, as long as the /// drag was started with this element under the mouse. Useful for implementing draggable @@ -919,6 +935,18 @@ pub trait InteractiveElement: Sized { self } + /// Bind the given callback to the mouse exit event, during the bubble phase. + /// The fluent API equivalent to [`Interactivity::on_mouse_exit`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + fn on_mouse_exit( + mut self, + listener: impl Fn(&MouseExitEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.interactivity().on_mouse_exit(listener); + self + } + /// Bind the given callback to the mouse drag event of the given type. Note that this /// will be called for all move events, inside or outside of this element, as long as the /// drag was started with this element under the mouse. Useful for implementing draggable @@ -1525,6 +1553,8 @@ pub(crate) type MousePressureListener = Box; pub(crate) type MouseMoveListener = Box; +pub(crate) type MouseExitListener = + Box; pub(crate) type ScrollWheelListener = Box; @@ -1907,6 +1937,7 @@ pub struct Interactivity { pub(crate) mouse_up_listeners: Vec, pub(crate) mouse_pressure_listeners: Vec, pub(crate) mouse_move_listeners: Vec, + pub(crate) mouse_exit_listeners: Vec, pub(crate) scroll_wheel_listeners: Vec, pub(crate) pinch_listeners: Vec, pub(crate) key_down_listeners: Vec, @@ -2152,6 +2183,7 @@ impl Interactivity { || !self.mouse_pressure_listeners.is_empty() || !self.mouse_down_listeners.is_empty() || !self.mouse_move_listeners.is_empty() + || !self.mouse_exit_listeners.is_empty() || !self.click_listeners.is_empty() || !self.aux_click_listeners.is_empty() || !self.scroll_wheel_listeners.is_empty() @@ -2531,6 +2563,13 @@ impl Interactivity { }) } + for listener in self.mouse_exit_listeners.drain(..) { + let hitbox = hitbox.clone(); + window.on_mouse_event(move |event: &MouseExitEvent, phase, window, cx| { + listener(event, phase, &hitbox, window, cx); + }) + } + for listener in self.scroll_wheel_listeners.drain(..) { let hitbox = hitbox.clone(); window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| { diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index bf3b33efc72..4dfa07b6b2c 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -24,10 +24,10 @@ use gpui::{ ClipboardItem, Context, CursorStyle, DismissEvent, Div, DragMoveEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, FontWeight, Hsla, InteractiveElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, Modifiers, ModifiersChangedEvent, - MouseButton, MouseDownEvent, ParentElement, PathPromptOptions, Pixels, Point, PromptLevel, - Render, ScrollStrategy, Stateful, Styled, Subscription, Task, UniformListScrollHandle, - WeakEntity, Window, actions, anchored, deferred, div, hsla, linear_color_stop, linear_gradient, - point, px, size, transparent_white, uniform_list, + MouseButton, MouseDownEvent, MouseExitEvent, ParentElement, PathPromptOptions, Pixels, Point, + PromptLevel, Render, ScrollStrategy, Stateful, Styled, Subscription, Task, + UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, div, hsla, + linear_color_stop, linear_gradient, point, px, size, transparent_white, uniform_list, }; use language::DiagnosticSeverity; use markdown_preview::markdown_preview_view::MarkdownPreviewView; @@ -2128,8 +2128,7 @@ impl ProjectPanel { fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { if cx.stop_active_drag(window) { - self.drag_target_entry.take(); - self.hover_expand_task.take(); + self.clear_drag_state(cx); return; } self.marked_entries.clear(); @@ -4713,6 +4712,17 @@ impl ProjectPanel { } } + fn clear_drag_state(&mut self, cx: &mut Context) { + let had_drag_state = self.drag_target_entry.take().is_some() + | self.folded_directory_drag_target.take().is_some() + | self.hover_scroll_task.take().is_some() + | self.hover_expand_task.take().is_some() + | self.previous_drag_position.take().is_some(); + if had_drag_state { + cx.notify(); + } + } + fn is_copy_modifier_set(modifiers: &Modifiers) -> bool { cfg!(target_os = "macos") && modifiers.alt || cfg!(not(target_os = "macos")) && modifiers.control @@ -5756,8 +5766,7 @@ impl ProjectPanel { )) .on_drop(cx.listener( move |this, external_paths: &ExternalPaths, window, cx| { - this.drag_target_entry = None; - this.hover_scroll_task.take(); + this.clear_drag_state(cx); this.drop_external_files(external_paths.paths(), entry_id, window, cx); cx.stop_propagation(); }, @@ -5886,9 +5895,7 @@ impl ProjectPanel { }) .on_drop(cx.listener( move |this, selections: &DraggedSelection, window, cx| { - this.drag_target_entry = None; - this.hover_scroll_task.take(); - this.hover_expand_task.take(); + this.clear_drag_state(cx); if folded_directory_drag_target.is_some() { return; } @@ -6281,9 +6288,7 @@ impl ProjectPanel { )) .on_drop(cx.listener( move |this, selections: &DraggedSelection, window, cx| { - this.hover_scroll_task.take(); - this.drag_target_entry = None; - this.folded_directory_drag_target = None; + this.clear_drag_state(cx); if let Some(target_entry_id) = target_entry_id { this.drag_onto( selections, @@ -6380,9 +6385,7 @@ impl ProjectPanel { div.when(drag_and_drop_enabled, |div| { div.on_drop(cx.listener( move |this, selections: &DraggedSelection, window, cx| { - this.hover_scroll_task.take(); - this.drag_target_entry = None; - this.folded_directory_drag_target = None; + this.clear_drag_state(cx); if let Some(target_entry_id) = target_entry_id { this.drag_onto(selections, target_entry_id, is_file, window, cx); } @@ -6855,7 +6858,7 @@ impl Render for ProjectPanel { this.previous_drag_position = Some(e.event.position); if !e.bounds.contains(&e.event.position) { - this.drag_target_entry = None; + this.clear_drag_state(cx); return; } this.hover_scroll_task.take(); @@ -6912,6 +6915,10 @@ impl Render for ProjectPanel { .when(panel_settings.drag_and_drop, |this| { this.on_drag_move(cx.listener(handle_drag_move::)) .on_drag_move(cx.listener(handle_drag_move::)) + .on_mouse_exit(cx.listener(|this, _: &MouseExitEvent, window, cx| { + cx.stop_active_drag(window); + this.clear_drag_state(cx); + })) }) .size_full() .relative() @@ -7298,8 +7305,7 @@ impl Render for ProjectPanel { )) .on_drop(cx.listener( move |this, external_paths: &ExternalPaths, window, cx| { - this.drag_target_entry = None; - this.hover_scroll_task.take(); + this.clear_drag_state(cx); if let Some(entry_id) = this.state.last_worktree_root_id { this.drop_external_files( external_paths.paths(), @@ -7313,8 +7319,7 @@ impl Render for ProjectPanel { )) .on_drop(cx.listener( move |this, selections: &DraggedSelection, window, cx| { - this.drag_target_entry = None; - this.hover_scroll_task.take(); + this.clear_drag_state(cx); if let Some(entry_id) = this.state.last_worktree_root_id { this.drag_onto(selections, entry_id, false, window, cx); } @@ -7439,8 +7444,7 @@ impl Render for ProjectPanel { }) .on_drop(cx.listener( move |this, external_paths: &ExternalPaths, window, cx| { - this.drag_target_entry = None; - this.hover_scroll_task.take(); + this.clear_drag_state(cx); if let Some(task) = this .workspace .update(cx, |workspace, cx| { From 961f4f202465629fe00ccf55bd9ebf01d643b931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=B7=E6=B8=A1?= Date: Tue, 7 Jul 2026 18:59:45 +0800 Subject: [PATCH 115/197] gpui: Refresh mouse position after bounds changes (#60421) ## Summary - Refresh GPUI's cached mouse position when window bounds change so hover hit-testing uses the current cursor position after live resize. - Return X11 mouse positions in window-relative logical pixels to keep `PlatformWindow::mouse_position()` consistent with other backends. Fixes #57354 ## Testing - `cargo fmt -p gpui -p gpui_linux` - `cargo check -p gpui_linux` - `cargo check -p gpui` ## Suggested .rules additions - In GPUI platform backends, `PlatformWindow::mouse_position()` should return window-relative logical pixels; use separate APIs or fields for global/device-pixel coordinates. Release Notes: - Fixed incorrect hover state while resizing GPUI windows. --- crates/gpui/src/window.rs | 1 + crates/gpui_linux/src/linux/x11/window.rs | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index fcf10ac5576..f4bd64ecba5 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -2250,6 +2250,7 @@ impl Window { self.scale_factor = self.platform_window.scale_factor(); self.viewport_size = self.platform_window.content_size(); self.display_id = self.platform_window.display().map(|display| display.id()); + self.mouse_position = self.platform_window.mouse_position(); self.refresh(); diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index 147630d054d..d2edb72408b 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -1410,7 +1410,11 @@ impl PlatformWindow for X11Window { ) .log_err() .map_or(Point::new(Pixels::ZERO, Pixels::ZERO), |reply| { - Point::new((reply.root_x as u32).into(), (reply.root_y as u32).into()) + let scale_factor = self.0.state.borrow().scale_factor; + Point::new( + px(reply.win_x as f32 / scale_factor), + px(reply.win_y as f32 / scale_factor), + ) }) } From fbd911ed3e0fdb98ab5ae7a66679774d4986db0a Mon Sep 17 00:00:00 2001 From: Lillian Rose <118373811+lillianrubyrose@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:04:15 +0000 Subject: [PATCH 116/197] Limit SVG Pixmap size to avoid GPUI texture allocation errors (#56468) 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) (Arguably no, but it's an okay compromise methinks) - [ ] Tests cover the new/changed behavior (I'm unsure how one would properly test this, sorry!) - [x] Performance impact has been considered and is acceptable Closes #56466 To be completely honest I don't know if this is a good fix or not, it does fix the problem I was running into where opening the large mermaid diagram would blow up VRAM. It doesn't look amazing visually but I would consider this behavior better, if it's not a good fix then that's okay:) I chose 8192 because 8192 only brings VRAM usage up ~100MB in my testing while 16384 brought my VRAM usage up to about 1GB from 150-200MB, for lower end systems this seems unacceptable. Before: I can't take a screenshot of the before at this point because it eats my system VRAM & Memory too fast. As a text description; It would show a large empty rectangle where the mermaid diagram should be and blow up Zed's VRAM usage from ~300MB to ~22GB (all of the available VRAM in my system) After: image image (Up from ~150MB), the 257MiB figure is the GPU Memory. Release Notes: - N/A? Co-authored-by: Lukas Wirth --- crates/gpui/src/svg_renderer.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/gpui/src/svg_renderer.rs b/crates/gpui/src/svg_renderer.rs index 5abd3341d22..35124b15ef8 100644 --- a/crates/gpui/src/svg_renderer.rs +++ b/crates/gpui/src/svg_renderer.rs @@ -228,13 +228,30 @@ impl SvgRenderer { } fn render_pixmap(&self, bytes: &[u8], size: SvgSize) -> Result { + // Cap the size of the rendered pixmap to avoid texture allocation panics + // Related issue: #56466 + const MAX_SIZE: f32 = 8192.0; + let tree = usvg::Tree::from_data(bytes, &self.usvg_options)?; let svg_size = tree.size(); - let scale = match size { + let mut scale = match size { SvgSize::Size(size) => size.width.0 as f32 / svg_size.width(), SvgSize::ScaleFactor(scale) => scale, }; + let width = svg_size.width() * scale; + if width > MAX_SIZE { + log::warn!("Attempted to render pixmap where width ({width}) > MAX_SIZE ({MAX_SIZE})"); + scale *= MAX_SIZE / width; + } + let height = svg_size.height() * scale; + if height > MAX_SIZE { + log::warn!( + "Attempted to render pixmap where height ({height}) > MAX_SIZE ({MAX_SIZE})" + ); + scale *= MAX_SIZE / height; + } + // Render the SVG to a pixmap with the specified width and height. let mut pixmap = resvg::tiny_skia::Pixmap::new( (svg_size.width() * scale) as u32, From c05b439174c95a51df92a797c2a17933ae44a59e Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 7 Jul 2026 13:45:59 +0200 Subject: [PATCH 117/197] acp: Show descriptions for elicitation options (#60527) image Release Notes: - N/A --- Cargo.lock | 12 +- Cargo.toml | 2 +- .../src/conversation_view/elicitation.rs | 117 ++++++++++++++---- 3 files changed, 102 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1ada6ef9e5c..c0711dee737 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -322,9 +322,9 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16302d16c7531355db16593d99c38c8297db0c4653aa7dd80c3556bb17f4cd8c" +checksum = "ed34c8297a72c9d99fc7493ba6370f45c69fdcd51fdfb469d80f266cf63e5a98" dependencies = [ "agent-client-protocol-derive", "agent-client-protocol-schema", @@ -344,9 +344,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-derive" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b37d552feb6981a0109febda6b71fc723678cd065974c2d76279c19c407095" +checksum = "0de0877cd41073fa0a124edb972df0d02e88c70adde11f49cf2231fd63aae738" dependencies = [ "quote", "syn 2.0.117", @@ -354,9 +354,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-schema" -version = "1.1.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac542aba230234b1591ace7286a47c0514fe3efc3037d43296bde31ba7ee5728" +checksum = "06679e1542356341f4550ccfb16338b64f37f6af70de2105446ef6fbb078234c" dependencies = [ "anyhow", "derive_more", diff --git a/Cargo.toml b/Cargo.toml index 15c7b4401a0..3611fce865b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -508,7 +508,7 @@ accesskit = "0.24.0" accesskit_macos = "0.26.0" accesskit_unix = "0.21.0" accesskit_windows = "0.33.1" -agent-client-protocol = { version = "=1.0.1", features = ["unstable"] } +agent-client-protocol = { version = "=1.1.0", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "4c129667ce56611becdc82de6e28218c80e2e88f" } any_vec = "0.14" diff --git a/crates/agent_ui/src/conversation_view/elicitation.rs b/crates/agent_ui/src/conversation_view/elicitation.rs index ae3f3a81b85..3eaaa0652bd 100644 --- a/crates/agent_ui/src/conversation_view/elicitation.rs +++ b/crates/agent_ui/src/conversation_view/elicitation.rs @@ -16,6 +16,7 @@ use ui::{ struct ElicitationOption { value: String, label: SharedString, + description: Option, } enum ElicitationFieldState { @@ -429,6 +430,51 @@ mod tests { ); } + #[test] + fn single_select_options_include_titled_descriptions() { + let schema = acp::StringPropertySchema::new().one_of(vec![ + acp::EnumOption::new("production", "Production").description("Use live resources"), + ]); + + let options = single_select_options(&schema); + + let [option] = options.as_slice() else { + panic!("expected one option, got {}", options.len()); + }; + assert_eq!(option.value, "production"); + assert_eq!(option.label.to_string(), "Production"); + assert_eq!( + option + .description + .as_ref() + .map(|description| description.to_string()), + Some("Use live resources".to_string()) + ); + } + + #[test] + fn multi_select_options_include_titled_descriptions() { + let schema = acp::MultiSelectPropertySchema::titled(vec![ + acp::EnumOption::new("repository", "Repository Access") + .description("Read and update repositories"), + ]); + + let options = multi_select_options(&schema); + + let [option] = options.as_slice() else { + panic!("expected one option, got {}", options.len()); + }; + assert_eq!(option.value, "repository"); + assert_eq!(option.label.to_string(), "Repository Access"); + assert_eq!( + option + .description + .as_ref() + .map(|description| description.to_string()), + Some("Read and update repositories".to_string()) + ); + } + #[gpui::test] fn form_state_preserves_string_whitespace(cx: &mut TestAppContext) { crate::conversation_view::tests::init_test(cx); @@ -773,8 +819,10 @@ fn preview_form_schema() -> acp::ElicitationSchema { .title("Environment") .description("Select the environment this credential should target.") .one_of(vec![ - acp::EnumOption::new("production", "Production"), - acp::EnumOption::new("staging", "Staging"), + acp::EnumOption::new("production", "Production") + .description("Use the live account and production resources."), + acp::EnumOption::new("staging", "Staging") + .description("Validate changes against staging data first."), acp::EnumOption::new("development", "Development"), ]) .default_value("staging"), @@ -783,8 +831,10 @@ fn preview_form_schema() -> acp::ElicitationSchema { .property( "scopes", acp::MultiSelectPropertySchema::titled(vec![ - acp::EnumOption::new("profile", "Profile"), - acp::EnumOption::new("repository", "Repository Access"), + acp::EnumOption::new("profile", "Profile") + .description("Read account identity and basic profile details."), + acp::EnumOption::new("repository", "Repository Access") + .description("Read and update repositories connected to this account."), acp::EnumOption::new("terminal", "Terminal Commands"), ]) .title("Access") @@ -810,6 +860,7 @@ fn single_select_options(schema: &acp::StringPropertySchema) -> Vec Vec Vec { match &schema.items { - acp::MultiSelectItems::Untitled(items) => items + acp::MultiSelectItems::String(items) => items .values .iter() .map(|value| ElicitationOption { value: value.clone(), label: SharedString::from(value.clone()), + description: None, }) .collect(), acp::MultiSelectItems::Titled(items) => items @@ -857,6 +910,7 @@ fn multi_select_options(schema: &acp::MultiSelectPropertySchema) -> Vec Vec::new(), @@ -1310,11 +1364,7 @@ impl<'a> ElicitationCard<'a> { cx, ); }) - .child( - div() - .mt_0p5() - .child(Checkbox::new(checkbox_id, checkbox_state)), - ) + .child(div().child(Checkbox::new(checkbox_id, checkbox_state))) .child( v_flex() .gap_0p5() @@ -1412,8 +1462,8 @@ impl<'a> ElicitationCard<'a> { ))) .w_full() .min_h(rems_from_px(28.)) - .items_center() - .gap_2() + .items_start() + .gap_1p5() .rounded_sm() .border_1() .border_color(field_border_color.opacity(0.5)) @@ -1430,8 +1480,8 @@ impl<'a> ElicitationCard<'a> { cx, ); }) - .child(Checkbox::new(checkbox_id, checkbox_state)) - .child(Label::new(option.label).size(LabelSize::Small).truncate()) + .child(div().child(Checkbox::new(checkbox_id, checkbox_state))) + .child(Self::render_option_content(option)) })) .into_any_element() } @@ -1479,8 +1529,8 @@ impl<'a> ElicitationCard<'a> { .id(option_id) .w_full() .min_h(rems_from_px(28.)) - .items_center() - .gap_2() + .items_start() + .gap_1p5() .rounded_sm() .border_1() .border_color(border_color.opacity(0.5)) @@ -1496,16 +1546,39 @@ impl<'a> ElicitationCard<'a> { cx, ); }) - .child(Self::render_radio_indicator( - is_selected, - border_color, - control_background, - )) - .child(Label::new(option.label).size(LabelSize::Small).truncate()) + .child( + div() + .size(Checkbox::container_size()) + .flex_none() + .flex() + .items_center() + .justify_center() + .child(Self::render_radio_indicator( + is_selected, + border_color, + control_background, + )), + ) + .child(Self::render_option_content(option)) })) .into_any_element() } + fn render_option_content(option: ElicitationOption) -> Div { + v_flex() + .min_w_0() + .flex_1() + .gap_0p5() + .child(Label::new(option.label).size(LabelSize::Small).truncate()) + .when_some(option.description, |this, description| { + this.child( + Label::new(description) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }) + } + fn option_row_background(is_selected: bool, cx: &App) -> Hsla { let editor_background = cx.theme().colors().editor_background; if is_selected { From f360136f199373607c4449414145b3287baa5d85 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Tue, 7 Jul 2026 13:48:14 +0200 Subject: [PATCH 118/197] project: Create LSP file watcher on the background (#60530) Release Notes: - N/A or Added/Fixed/Improved ... --- crates/project/src/lsp_store.rs | 7 ++++++- crates/session/src/session.rs | 8 +++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 666ba714f97..96f02fcb778 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -14409,7 +14409,12 @@ impl LanguageServerWatchedPaths { cx.spawn({ async move |_, cx| { maybe!(async move { - let mut push_updates = fs.watch(&abs_path, LSP_ABS_PATH_OBSERVE).await; + let mut push_updates = cx + .background_spawn({ + let abs_path = abs_path.clone(); + async move { fs.watch(&abs_path, LSP_ABS_PATH_OBSERVE).await } + }) + .await; while let Some(update) = push_updates.0.next().await { let action = lsp_store .update(cx, |this, _| { diff --git a/crates/session/src/session.rs b/crates/session/src/session.rs index 76f2398b382..04109ce1b2f 100644 --- a/crates/session/src/session.rs +++ b/crates/session/src/session.rs @@ -70,8 +70,7 @@ impl AppSession { pub fn new(session: Session, cx: &Context) -> Self { let _subscriptions = vec![cx.on_app_quit(Self::app_will_quit)]; - #[cfg(not(any(test, feature = "test-support")))] - let _serialization_task = { + let _serialization_task = if cfg!(not(any(test, feature = "test-support"))) { let db = KeyValueStore::global(cx); cx.spawn(async move |_, cx| { // Disabled in tests: the infinite loop bypasses "parking forbidden" checks, @@ -92,11 +91,10 @@ impl AppSession { } } }) + } else { + Task::ready(()) }; - #[cfg(any(test, feature = "test-support"))] - let _serialization_task = Task::ready(()); - Self { session, _subscriptions, From a94fa5cf1913b0d80dfaf8fa495486091ac79cb7 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:15:07 -0300 Subject: [PATCH 119/197] git_graph: Add design adjustments (#60469) This PR adds adjustments to the tree view, making it more consistent with all other tree view displays in the app (e.g., displaying indent guides, removing chevron toggle, etc.), and also fixes an issue where the commit message scrollbar was scrolling up with the message. Release Notes: - N/A --- crates/git_ui/src/git_graph.rs | 200 +++++++++++----------- crates/git_ui/src/git_panel.rs | 46 +---- crates/git_ui/src/project_diff.rs | 6 +- crates/outline_panel/src/outline_panel.rs | 2 +- crates/project_panel/src/project_panel.rs | 6 +- crates/ui/src/components/data_table.rs | 8 +- crates/ui/src/components/indent_guides.rs | 17 +- 7 files changed, 137 insertions(+), 148 deletions(-) diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index b360e1ec578..90efa57d228 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -52,7 +52,7 @@ use theme::AccentColors; use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem}; use ui::{ Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, ContextMenuEntry, DiffStat, - Divider, HeaderResizeInfo, HighlightedLabel, ListItem, ListItemSpacing, + Divider, HeaderResizeInfo, HighlightedLabel, IndentGuideColors, ListItem, ListItemSpacing, RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState, TableRenderContext, TableResizeBehavior, Tooltip, WithScrollbar, bind_redistributable_columns, prelude::*, redistribute_hidden_fractions, redistribute_hidden_widths, @@ -73,9 +73,8 @@ const RESIZE_HANDLE_WIDTH: f32 = 8.0; const COPIED_STATE_DURATION: Duration = Duration::from_secs(2); const COMMIT_TAG_LIST_WIDTH_IN_REMS: Rems = rems(10.); const CUSTOM_GIT_COMMANDS_DOCS_SLUG: &str = "tasks#custom-git-commands"; +const TREE_INDENT: f32 = 20.0; const TABLE_COLUMN_COUNT: usize = 4; -// Extra vertical breathing room added to the UI line height when computing -// the git graph's row height, so commit dots and lines have space around them. const ROW_VERTICAL_PADDING: Pixels = px(4.0); struct CopiedState { @@ -278,8 +277,6 @@ impl ChangedFileEntry { workspace: WeakEntity, _cx: &App, ) -> AnyElement { - const TREE_INDENT: f32 = 12.0; - let file_name = self.file_name.clone(); let dir_path = self.dir_path.clone(); @@ -358,8 +355,6 @@ struct ChangedFileDirectoryEntry { impl ChangedFileDirectoryEntry { fn render(&self, ix: usize, git_graph: WeakEntity, cx: &App) -> AnyElement { - const TREE_INDENT: f32 = 12.0; - let path = self.path.clone(); let expanded = self.expanded; let folder_icon = FileIcons::get_folder_icon(expanded, path.as_std_path(), cx) @@ -381,22 +376,6 @@ impl ChangedFileDirectoryEntry { .spacing(ListItemSpacing::Sparse) .indent_level(self.depth) .indent_step_size(px(TREE_INDENT)) - .toggle(Some(expanded)) - .always_show_disclosure_icon(true) - .on_toggle({ - let path = path.clone(); - let git_graph = git_graph.clone(); - move |_, _, cx| { - git_graph - .update(cx, |git_graph, cx| { - git_graph - .changed_files_expanded_dirs - .insert(path.clone(), !expanded); - cx.notify(); - }) - .ok(); - } - }) .start_slot(folder_icon) .child( Label::new(self.name.clone()) @@ -3018,7 +2997,7 @@ impl GitGraph { }; CommitAvatar::new(&full_sha, author_email_for_avatar, remote.as_ref()) - .size(px(40.)) + .size(px(32.)) .render(window, cx) }; @@ -3056,6 +3035,25 @@ impl GitGraph { Rc::default() }; + let is_tree_view = self.changed_files_view_mode.is_tree(); + let view_toggle = IconButton::new("toggle-changed-files-view", IconName::ListTree) + .icon_size(IconSize::Small) + .toggle_state(self.changed_files_view_mode.is_tree()) + .tooltip({ + let tooltip = if is_tree_view { + "Show Flat View" + } else { + "Show Tree View" + }; + move |_, cx| Tooltip::for_action(tooltip, &ToggleChangedFilesView, cx) + }) + .on_click(cx.listener(|this, _, _window, cx| { + this.changed_files_view_mode = this.changed_files_view_mode.toggled(); + this.changed_files_scroll_handle + .scroll_to_item(0, ScrollStrategy::Top); + cx.notify(); + })); + v_flex() .min_w(px(300.)) .h_full() @@ -3090,17 +3088,12 @@ impl GitGraph { .py_1() .w_full() .items_center() - .gap_1() .child(avatar) + .child(Label::new(author_name).mt_1p5()) .child( - v_flex() - .items_center() - .child(Label::new(author_name)) - .child( - Label::new(date_string) - .color(Color::Muted) - .size(LabelSize::Small), - ), + Label::new(date_string) + .color(Color::Muted) + .size(LabelSize::Small), ), ) .children((!ref_names.is_empty()).then(|| { @@ -3258,69 +3251,40 @@ impl GitGraph { .child( v_flex() .min_w_0() - .p_2() .flex_1() - .gap_1() + .overflow_hidden() .child( h_flex() + .p_2() + .pr_3() + .pb_1() .gap_1() .w_full() .justify_between() - .child( - Label::new(format!( - "{} Changed {}", - changed_files_count, - if changed_files_count == 1 { - "File" - } else { - "Files" - } - )) - .size(LabelSize::Small) - .color(Color::Muted), - ) .child( h_flex() .gap_1() - .child(DiffStat::new( - "commit-diff-stat", - total_lines_added, - total_lines_removed, - )) .child( - IconButton::new( - "toggle-changed-files-view", - IconName::ListTree, - ) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .toggle_state(self.changed_files_view_mode.is_tree()) - .tooltip({ - let tooltip = if self.changed_files_view_mode.is_tree() - { - "Show Flat View" + Label::new(format!( + "{} Changed {}", + changed_files_count, + if changed_files_count == 1 { + "File" } else { - "Show Tree View" - }; - move |_, cx| { - Tooltip::for_action( - tooltip, - &ToggleChangedFilesView, - cx, - ) + "Files" } - }) - .on_click( - cx.listener(|this, _, _window, cx| { - this.changed_files_view_mode = - this.changed_files_view_mode.toggled(); - this.changed_files_scroll_handle - .scroll_to_item(0, ScrollStrategy::Top); - cx.notify(); - }), - ), - ), - ), + )) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(Divider::vertical()) + .child(view_toggle), + ) + .child(DiffStat::new( + "commit-diff-stat", + total_lines_added, + total_lines_removed, + )), ) .child( div() @@ -3329,7 +3293,7 @@ impl GitGraph { .min_h_0() .child({ let flat_entries = changed_file_entries; - let is_tree_view = self.changed_files_view_mode.is_tree(); + let entry_count = if is_tree_view { tree_entries.len() } else { @@ -3339,6 +3303,8 @@ impl GitGraph { let repository = repository.downgrade(); let workspace = self.workspace.clone(); let git_graph = cx.weak_entity(); + let indent_tree_entries = tree_entries.clone(); + uniform_list( "changed-files-list", entry_count, @@ -3381,8 +3347,34 @@ impl GitGraph { .collect() }, ) + .when(is_tree_view, |list| { + list.with_decoration( + ui::indent_guides( + px(TREE_INDENT), + IndentGuideColors::panel(cx), + ) + .with_left_offset( + ui::LIST_ITEM_INDENT_GUIDE_LEFT_OFFSET - px(2.), + ) + .with_compute_indents_fn( + cx.entity(), + move |_, range, _window, _cx| { + range + .map(|ix| match indent_tree_entries.get(ix) { + Some(ChangedFileTreeEntry::Directory( + entry, + )) => entry.depth, + Some(ChangedFileTreeEntry::File(entry)) => { + entry.depth + } + None => 0, + }) + .collect() + }, + ), + ) + }) .size_full() - .ml_neg_1() .track_scroll(&self.changed_files_scroll_handle) }) .vertical_scrollbar_for(&self.changed_files_scroll_handle, window, cx), @@ -3393,6 +3385,11 @@ impl GitGraph { h_flex().p_1p5().w_full().child( Button::new("view-commit", "View Commit") .full_width() + .start_icon( + Icon::new(IconName::GitCommit) + .size(IconSize::Small) + .color(Color::Muted), + ) .style(ButtonStyle::OutlinedGhost) .on_click(cx.listener(|this, _, window, cx| { this.open_selected_commit_view(window, cx); @@ -3912,22 +3909,26 @@ impl GitGraph { // which causes problems with text reflow when using flexbox. // grid, on the other hand, doesn't appear to give taffy the same // problems. - .grid() + .w_full() + .max_h(line_height * 12.) .py_2() .pl_2() - .w_full() - .gap_1() + .grid() .grid_cols(1) - // Value of 12 taken from ./commit_view.rs:725 - .max_h(line_height * 12.) + .gap_1() .child( div() - .id("commit-message") - .text_sm() + .relative() .size_full() - .overflow_y_scroll() - .track_scroll(scroll_handle) - .child(MarkdownElement::new(message.clone(), message_style)) + .child( + div() + .id("commit-message") + .text_sm() + .size_full() + .overflow_y_scroll() + .track_scroll(scroll_handle) + .child(MarkdownElement::new(message.clone(), message_style)), + ) .vertical_scrollbar_for(scroll_handle, window, cx), ) .into_any_element() @@ -3961,11 +3962,10 @@ impl Render for GitGraph { let label = Label::new(message) .color(Color::Muted) .size(LabelSize::Large); - div() + + h_flex() .size_full() - .h_flex() .gap_1() - .items_center() .justify_center() .child(label) .when(is_loading && error.is_none(), |this| { @@ -4013,7 +4013,7 @@ impl Render for GitGraph { h_flex() .size_full() .child( - div() + v_flex() .flex_1() .min_w_0() .size_full() diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 90463f1b9e1..b1667adff59 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -39,11 +39,10 @@ use git::{ ViewFile, parse_git_remote_url, }; use gpui::{ - AbsoluteLength, Action, Anchor, AnyElement, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, + AbsoluteLength, Action, Anchor, AnyElement, AsyncApp, AsyncWindowContext, ClickEvent, DismissEvent, Empty, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, MouseButton, MouseDownEvent, Pixels, Point, PromptLevel, ScrollStrategy, Subscription, Task, TaskExt, - TextStyle, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, point, size, - uniform_list, + TextStyle, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, uniform_list, }; use itertools::Itertools; use language::{Buffer, BufferEvent, File}; @@ -82,9 +81,8 @@ use theme_settings::ThemeSettings; use time::OffsetDateTime; use ui::{ ButtonLike, Checkbox, ContextMenu, ContextMenuEntry, Divider, ElevationIndex, - IndentGuideColors, KeyBinding, PopoverMenu, PopoverMenuHandle, ProjectEmptyState, - RenderedIndentGuide, ScrollAxes, Scrollbars, SplitButton, Tab, TintColor, Tooltip, - WithScrollbar, prelude::*, + IndentGuideColors, KeyBinding, PopoverMenu, PopoverMenuHandle, ProjectEmptyState, ScrollAxes, + Scrollbars, SplitButton, Tab, TintColor, Tooltip, WithScrollbar, prelude::*, }; use util::paths::PathStyle; use util::{ResultExt, TryFutureExt, markdown::MarkdownInlineCode, maybe, rel_path::RelPath}; @@ -100,6 +98,8 @@ const GIT_PANEL_KEY: &str = "GitPanel"; const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); // TODO: We should revise this part. It seems the indentation width is not aligned with the one in project panel const TREE_INDENT: f32 = 16.0; +// Horizontal offset that aligns the tree indent guides with the row icon column. +const INDENT_GUIDE_LEFT_OFFSET: gpui::Pixels = gpui::px(19.); actions!( git_panel, @@ -6381,43 +6381,15 @@ impl GitPanel { }), ) .when(is_tree_view, |list| { - let indent_size = px(TREE_INDENT); list.with_decoration( - ui::indent_guides(indent_size, IndentGuideColors::panel(cx)) + ui::indent_guides(px(TREE_INDENT), IndentGuideColors::panel(cx)) + .with_left_offset(INDENT_GUIDE_LEFT_OFFSET) .with_compute_indents_fn( cx.entity(), |this, range, _window, _cx| { this.compute_visible_depths(range) }, - ) - .with_render_fn(cx.entity(), |_, params, _, _| { - // Magic number to align the tree item is 3 here - // because we're using 12px as the left-side padding - // and 3 makes the alignment work with the bounding box of the icon - let left_offset = px(TREE_INDENT + 3_f32); - let indent_size = params.indent_size; - let item_height = params.item_height; - - params - .indent_guides - .into_iter() - .map(|layout| { - let bounds = Bounds::new( - point( - layout.offset.x * indent_size + left_offset, - layout.offset.y * item_height, - ), - size(px(1.), layout.length * item_height), - ); - RenderedIndentGuide { - bounds, - layout, - is_active: false, - hitbox: None, - } - }) - .collect() - }), + ), ) }) .group("entries") diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index d674be55766..445eaf1e704 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -799,6 +799,8 @@ impl Render for ProjectDiffToolbar { let (additions, deletions) = project_diff.read(cx).calculate_changed_lines(cx); let is_multibuffer_empty = project_diff.read(cx).multibuffer(cx).read(cx).is_empty(); + let stage_all_button_width = rems(5.); + h_flex() .my_neg_1() .py_1() @@ -894,7 +896,7 @@ impl Render for ProjectDiffToolbar { |this| { this.child( Button::new("unstage-all", "Unstage All") - .width(rems_from_px(80.)) + .width(stage_all_button_width) .tooltip(Tooltip::for_action_title_in( "Unstage All Changes", &UnstageAll, @@ -911,7 +913,7 @@ impl Render for ProjectDiffToolbar { |this| { this.child( Button::new("stage-all", "Stage All") - .width(rems_from_px(80.)) + .width(stage_all_button_width) .disabled(!button_states.stage_all) .tooltip(Tooltip::for_action_title_in( "Stage All Changes", diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index a8786aaa40c..7c3845eb98f 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -4752,7 +4752,7 @@ impl OutlinePanel { } }) .with_render_fn(cx.entity(), move |outline_panel, params, _, _| { - const LEFT_OFFSET: Pixels = px(14.); + const LEFT_OFFSET: Pixels = ui::LIST_ITEM_INDENT_GUIDE_LEFT_OFFSET; let indent_size = params.indent_size; let item_height = params.item_height; diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 4dfa07b6b2c..8b830513b2c 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -7083,7 +7083,8 @@ impl Render for ProjectPanel { .with_render_fn( cx.entity(), move |this, params, _, cx| { - const LEFT_OFFSET: Pixels = px(14.); + const LEFT_OFFSET: Pixels = + ui::LIST_ITEM_INDENT_GUIDE_LEFT_OFFSET; const PADDING_Y: Pixels = px(4.); const HITBOX_OVERDRAW: Pixels = px(3.); @@ -7179,7 +7180,8 @@ impl Render for ProjectPanel { .with_render_fn( cx.entity(), move |_, params, _, _| { - const LEFT_OFFSET: Pixels = px(14.); + const LEFT_OFFSET: Pixels = + ui::LIST_ITEM_INDENT_GUIDE_LEFT_OFFSET; let indent_size = params.indent_size; let item_height = params.item_height; diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index 805c93708ac..8d785fec8a0 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -750,13 +750,11 @@ pub fn render_table_header( let shared_element_id: SharedString = format!("table-{}", element_id).into(); let pinned_cols = table_context.pinned_cols; - let outer = div() - .flex() - .flex_row() - .items_center() + let outer = h_flex() + .py_1() .w_full() .border_b_1() - .border_color(cx.theme().colors().border); + .border_color(cx.theme().colors().border_variant); let use_ui_font = table_context.use_ui_font; let resize_info_ref = resize_info.as_ref(); diff --git a/crates/ui/src/components/indent_guides.rs b/crates/ui/src/components/indent_guides.rs index 9e78b0d4bf6..f83dda592f6 100644 --- a/crates/ui/src/components/indent_guides.rs +++ b/crates/ui/src/components/indent_guides.rs @@ -28,9 +28,14 @@ impl IndentGuideColors { } } +/// Horizontal offset that lines an indent guide up with the icon column of a +/// standard [`ListItem`](crate::ListItem)-based row. +pub const LIST_ITEM_INDENT_GUIDE_LEFT_OFFSET: Pixels = px(15.); + pub struct IndentGuides { colors: IndentGuideColors, indent_size: Pixels, + left_offset: Pixels, compute_indents_fn: Option, &mut Window, &mut App) -> SmallVec<[usize; 64]>>>, render_fn: Option< @@ -49,6 +54,7 @@ pub fn indent_guides(indent_size: Pixels, colors: IndentGuideColors) -> IndentGu IndentGuides { colors, indent_size, + left_offset: px(0.), compute_indents_fn: None, render_fn: None, on_click: None, @@ -56,6 +62,15 @@ pub fn indent_guides(indent_size: Pixels, colors: IndentGuideColors) -> IndentGu } impl IndentGuides { + /// Sets a horizontal offset applied to every guide, used to line the guides + /// up with the icon column of the list's rows. Ignored when a custom render + /// function is set via [`Self::with_render_fn`], which is responsible for its + /// own positioning. + pub fn with_left_offset(mut self, left_offset: Pixels) -> Self { + self.left_offset = left_offset; + self + } + /// Sets the callback that will be called when the user clicks on an indent guide. pub fn on_click( mut self, @@ -124,7 +139,7 @@ impl IndentGuides { .map(|layout| RenderedIndentGuide { bounds: Bounds::new( point( - layout.offset.x * self.indent_size, + layout.offset.x * self.indent_size + self.left_offset, layout.offset.y * item_height, ), size(px(1.), layout.length * item_height), From fc827a218e979062b079efbd448947989fb86ab8 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 7 Jul 2026 14:49:19 +0200 Subject: [PATCH 120/197] Update issue ranking script dependencies (#60345) Release Notes: - N/A --- script/update_top_ranking_issues/uv.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/script/update_top_ranking_issues/uv.lock b/script/update_top_ranking_issues/uv.lock index f1b3ec28515..e4fd17159ea 100644 --- a/script/update_top_ranking_issues/uv.lock +++ b/script/update_top_ranking_issues/uv.lock @@ -58,11 +58,11 @@ wheels = [ [[package]] name = "idna" -version = "3.10" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -116,11 +116,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.18.0" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905, upload-time = "2024-05-04T13:42:02.013Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513, upload-time = "2024-05-04T13:41:57.345Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] From d564495dde0c344fa54e2432b91680fc604e495c Mon Sep 17 00:00:00 2001 From: Ruslan Semagin Date: Tue, 7 Jul 2026 16:50:03 +0300 Subject: [PATCH 121/197] git_ui: Show tags in Git panel history (#60534) # Objective - Make Git Panel History show commit tags so release/version markers are visible without opening the full Git graph or commit details. ## Solution - Store commit history entries with both the commit SHA and tag names from existing git graph data. - Render tag names as muted chips next to the commit subject. - Limit visible tags to 3 per commit and show `+N` for additional tags. ## Testing - Ran `cargo check -p git_ui`. - Manually verified on Linux with `script/zed-local --stateful .`. - Confirmed tags appear in Git Panel History. ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon] (https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase Git Panel History now shows commit tags inline as muted chips next to the commit subject. --- Release Notes: - Added tag labels to the Git Panel commit history. --------- Co-authored-by: Danilo Leal --- crates/git_ui/src/git_panel.rs | 124 ++++++++++++++++++++++++++------- 1 file changed, 98 insertions(+), 26 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index b1667adff59..590efaca2b7 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -27,8 +27,9 @@ use git::Oid; use git::commit::ParsedCommitMessage; use git::repository::{ Branch, CommitData, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions, - GitCommitTemplate, GitCommitter, LogOrder, LogSource, PushOptions, Remote, RemoteCommandOutput, - ResetMode, Upstream, UpstreamTracking, UpstreamTrackingStatus, get_git_committer, + GitCommitTemplate, GitCommitter, InitialGraphCommitData, LogOrder, LogSource, PushOptions, + Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking, UpstreamTrackingStatus, + get_git_committer, }; use git::stash::GitStash; use git::status::{DiffStat, StageStatus}; @@ -80,7 +81,7 @@ use strum::{IntoEnumIterator, VariantNames}; use theme_settings::ThemeSettings; use time::OffsetDateTime; use ui::{ - ButtonLike, Checkbox, ContextMenu, ContextMenuEntry, Divider, ElevationIndex, + ButtonLike, Checkbox, Chip, ContextMenu, ContextMenuEntry, Divider, ElevationIndex, IndentGuideColors, KeyBinding, PopoverMenu, PopoverMenuHandle, ProjectEmptyState, ScrollAxes, Scrollbars, SplitButton, Tab, TintColor, Tooltip, WithScrollbar, prelude::*, }; @@ -98,6 +99,7 @@ const GIT_PANEL_KEY: &str = "GitPanel"; const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); // TODO: We should revise this part. It seems the indentation width is not aligned with the one in project panel const TREE_INDENT: f32 = 16.0; +const MAX_HISTORY_TAG_CHIPS: usize = 3; // Horizontal offset that aligns the tree indent guides with the row icon column. const INDENT_GUIDE_LEFT_OFFSET: gpui::Pixels = gpui::px(19.); @@ -804,7 +806,7 @@ pub struct GitPanel { stash_entries: GitStash, active_tab: GitPanelTab, commit_history_scroll_handle: UniformListScrollHandle, - commit_history_shas: Option>, + commit_history_entries: Option>, focused_history_entry: Option, history_keyboard_nav: bool, _commit_message_buffer_subscription: Option, @@ -822,6 +824,25 @@ struct BulkStaging { anchor: RepoPath, } +#[derive(Clone)] +struct CommitHistoryEntry { + sha: Oid, + tag_names: Vec, +} + +impl From<&Arc> for CommitHistoryEntry { + fn from(commit: &Arc) -> Self { + Self { + sha: commit.sha, + tag_names: commit + .tag_names() + .into_iter() + .map(|tag_name| SharedString::from(tag_name.to_string())) + .collect(), + } + } +} + const MAX_PANEL_EDITOR_LINES: usize = 6; pub(crate) fn commit_message_editor( @@ -1087,7 +1108,7 @@ impl GitPanel { stash_entries: Default::default(), active_tab: GitPanelTab::Changes, commit_history_scroll_handle: UniformListScrollHandle::new(), - commit_history_shas: None, + commit_history_entries: None, focused_history_entry: None, history_keyboard_nav: false, _commit_message_buffer_subscription: None, @@ -5678,10 +5699,10 @@ impl GitPanel { v_flex().flex_1().size_full().overflow_hidden().map(|this| { let has_repo = self.active_repository.is_some(); let has_commits = self - .commit_history_shas + .commit_history_entries .as_ref() - .map_or(false, |shas| !shas.is_empty()); - let is_loading = self.commit_history_shas.is_none() && has_repo; + .map_or(false, |entries| !entries.is_empty()); + let is_loading = self.commit_history_entries.is_none() && has_repo; if is_loading { this.child( h_flex() @@ -5711,7 +5732,10 @@ impl GitPanel { } fn select_next_history_entry(&mut self, cx: &mut Context) { - let count = self.commit_history_shas.as_ref().map_or(0, Vec::len); + let count = self + .commit_history_entries + .as_ref() + .map_or(0, |entries| entries.len()); if count == 0 { return; } @@ -5727,7 +5751,10 @@ impl GitPanel { } fn select_previous_history_entry(&mut self, cx: &mut Context) { - let count = self.commit_history_shas.as_ref().map_or(0, Vec::len); + let count = self + .commit_history_entries + .as_ref() + .map_or(0, |entries| entries.len()); if count == 0 { return; } @@ -5746,14 +5773,18 @@ impl GitPanel { let Some(index) = self.focused_history_entry else { return; }; - let Some(sha) = self.commit_history_shas.as_ref().and_then(|s| s.get(index)) else { + let Some(entry) = self + .commit_history_entries + .as_ref() + .and_then(|entries| entries.get(index)) + else { return; }; let Some(active_repository) = self.active_repository.as_ref() else { return; }; CommitView::open( - sha.to_string(), + entry.sha.to_string(), active_repository.downgrade(), self.workspace.clone(), None, @@ -5794,7 +5825,7 @@ impl GitPanel { } GitPanelTab::Changes => { self.focus_handle.focus(window, cx); - self.commit_history_shas.take(); + self.commit_history_entries.take(); self.focused_history_entry = None; self._repo_subscriptions.clear(); } @@ -5833,7 +5864,7 @@ impl GitPanel { |this, _repo, event, cx| { if let RepositoryEvent::GraphEvent(_, _) = event { if this.active_tab == GitPanelTab::History { - this.fetch_commit_history_shas(cx); + this.fetch_commit_history_entries(cx); } } }, @@ -5844,10 +5875,10 @@ impl GitPanel { })); } - self.fetch_commit_history_shas(cx); + self.fetch_commit_history_entries(cx); } - fn fetch_commit_history_shas(&mut self, cx: &mut Context) { + fn fetch_commit_history_entries(&mut self, cx: &mut Context) { let Some(active_repository) = self.active_repository.as_ref() else { return; }; @@ -5860,9 +5891,13 @@ impl GitPanel { let log_source = LogSource::Branch(branch_name.into()); let log_order = LogOrder::DateOrder; - self.commit_history_shas = Some(active_repository.update(cx, |repository, cx| { + self.commit_history_entries = Some(active_repository.update(cx, |repository, cx| { let response = repository.graph_data(log_source, log_order, 0..usize::MAX, cx); - response.commits.iter().map(|commit| commit.sha).collect() + response + .commits + .iter() + .map(CommitHistoryEntry::from) + .collect() })); } @@ -5883,11 +5918,11 @@ impl GitPanel { window: &mut Window, cx: &mut Context, ) -> Option { - let shas = self.commit_history_shas.clone()?; + let entries = self.commit_history_entries.clone()?; let active_repository = self.active_repository.as_ref()?; let workspace = self.workspace.clone(); let repo_weak = active_repository.downgrade(); - let item_count = shas.len(); + let item_count = entries.len(); let commit_history_scroll_handle = self.commit_history_scroll_handle.clone(); let remote = self.git_remote(cx); @@ -5921,10 +5956,11 @@ impl GitPanel { let visible_data: Vec>> = repo_weak .update(cx, |repository, cx| { - shas[range.clone()] + entries[range.clone()] .iter() - .map(|sha| { - match repository.fetch_commit_data(*sha, false, cx) { + .map(|entry| { + match repository.fetch_commit_data(entry.sha, false, cx) + { CommitDataState::Loaded(data) => Some(data.clone()), CommitDataState::Loading(_) => None, } @@ -5933,16 +5969,17 @@ impl GitPanel { }) .unwrap_or_default(); - shas[range.clone()] + entries[range.clone()] .iter() .zip(visible_data) .enumerate() - .map(|(ix, (sha, data))| { + .map(|(ix, (entry, data))| { let index = range.start + ix; - let sha_string = sha.to_string(); + let sha_string = entry.sha.to_string(); let sha_shared: SharedString = sha_string.clone().into(); let short_sha: SharedString = sha_string[..7.min(sha_string.len())].to_string().into(); + let tag_names = entry.tag_names.clone(); let (subject, author_name, author_email, timestamp): ( SharedString, @@ -6017,7 +6054,42 @@ impl GitPanel { h_flex() .gap_1() .w_full() + .min_w_0() .child(Label::new(subject).truncate()) + .children((!tag_names.is_empty()).then(|| { + let hidden_tag_count = tag_names + .len() + .saturating_sub(MAX_HISTORY_TAG_CHIPS); + h_flex() + .gap_1() + .min_w_0() + .children( + tag_names + .iter() + .take(MAX_HISTORY_TAG_CHIPS) + .cloned() + .map(|tag_name| { + Chip::new(tag_name.clone()) + .truncate() + .tooltip(Tooltip::text( + tag_name, + )) + }), + ) + .when(hidden_tag_count > 0, |this| { + let hidden_tag_names = tag_names + [MAX_HISTORY_TAG_CHIPS..] + .join(", "); + this.child( + Chip::new(format!( + "+{hidden_tag_count}" + )) + .tooltip(Tooltip::text( + hidden_tag_names, + )), + ) + }) + })) .when(is_unpushed, |this| { this.child( Icon::new(IconName::ArrowUp) From bc29bcfe728e3ce158f36b6a1b15f3dff667fe02 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Tue, 7 Jul 2026 15:02:31 +0100 Subject: [PATCH 122/197] git: Load buffer git diff bases with a single batched git process (#59357) # Objective Whenever the git repository state is updated on disk (e.g., via staging, committing, branch switching, or stashing), `reload_buffer_diff_bases` is scheduled to reload the diff for all active buffers. This causes 2 git processes to be spawned for each open file which can become noticeable when many files are open https://github.com/zed-industries/zed/blob/5e32405669d0470baec345073dafffb552a18bae/crates/project/src/git_store.rs#L5179 ## Solution This PR introduces `load_revisions` which uses a single `git cat-file --batch` command to compute the diff for all files in the same git process. This prevents the need to sequentially schedule 2 git subprocesses per open buffer. I also changed `load_index_text` and `load_commited_text` to rely on `load_revisions` which simplifies the code. ## Testing I added a unittest and manually verified that Zed now only runs a single `git cat-file --batch` command instead of 2 `git show` processes per open buffer. On macOS I viewed the currently running git processes using: ```shell sudo eslogger exec | jq --unbuffered -r ' select(.event.exec?.target?.executable?.path? | strings | contains("git")) | (.event.exec?.args? // []) | join(" ") ' ``` ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable /cc @Veykril Release Notes: - Reduced number of git processes for calculating diff of open buffers when the repo state changes on disk --- crates/fs/src/fake_git_repo.rs | 40 +++--- crates/git/src/repository.rs | 212 +++++++++++++++++++++++++------- crates/project/src/git_store.rs | 57 +++++---- 3 files changed, 223 insertions(+), 86 deletions(-) diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 45ee57b35f5..ba23efb166b 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -162,28 +162,6 @@ impl FakeGitRepository { } impl GitRepository for FakeGitRepository { - fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option> { - let fut = self.with_state_async(false, move |state| { - state - .index_contents - .get(&path) - .context("not present in index") - .cloned() - }); - self.executor.spawn(async move { fut.await.ok() }).boxed() - } - - fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option> { - let fut = self.with_state_async(false, move |state| { - state - .head_contents - .get(&path) - .context("not present in HEAD") - .cloned() - }); - self.executor.spawn(async move { fut.await.ok() }).boxed() - } - fn load_commit_template(&self) -> BoxFuture<'_, Result>> { async { Ok(None) }.boxed() } @@ -264,6 +242,24 @@ impl GitRepository for FakeGitRepository { }) } + fn load_revisions(&self, revisions: Vec) -> BoxFuture<'_, Result>>> { + let fut = self.with_state_async(false, move |state| { + Ok(revisions + .into_iter() + .map(|rev| { + let (prefix, path) = rev.split_once(':')?; + let repo_path = RepoPath::new(path).ok()?; + match prefix { + "" => state.index_contents.get(&repo_path).cloned(), + "HEAD" => state.head_contents.get(&repo_path).cloned(), + _ => None, + } + }) + .collect()) + }); + self.executor.spawn(fut).boxed() + } + fn show(&self, commit: String) -> BoxFuture<'_, Result> { self.with_state_async(false, move |state| { let sha = state.refs.get(&commit).cloned().unwrap_or(commit); diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 2951a84b090..6c88ee4e688 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -801,12 +801,18 @@ pub trait GitRepository: Send + Sync { /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path. /// /// Also returns `None` for symlinks. - fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option>; + fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option> { + let future = self.load_revisions(vec![format!(":{}", path.as_unix_str())]); + async move { future.await.ok()?.pop()? }.boxed() + } /// Returns the contents of an entry in the repository's HEAD, or None if HEAD does not exist or has no entry for the given path. /// /// Also returns `None` for symlinks. - fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option>; + fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option> { + let future = self.load_revisions(vec![format!("HEAD:{}", path.as_unix_str())]); + async move { future.await.ok()?.pop()? }.boxed() + } fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result>; fn set_index_text( @@ -830,6 +836,8 @@ pub trait GitRepository: Send + Sync { /// Resolve a list of refs to SHAs. fn revparse_batch(&self, revs: Vec) -> BoxFuture<'_, Result>>>; + fn load_revisions(&self, revisions: Vec) -> BoxFuture<'_, Result>>>; + fn head_sha(&self) -> BoxFuture<'_, Option> { async move { self.revparse_batch(vec!["HEAD".into()]) @@ -1585,47 +1593,6 @@ impl GitRepository for RealGitRepository { .boxed() } - fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option> { - let git_binary = self.git_binary(); - let path_str = format!(":{}", path.as_unix_str()); - self.executor - .spawn(async move { - let git = git_binary; - let output = git - .build_command(&["show", &path_str]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .log_err()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout).ok() - }) - .boxed() - } - - fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option> { - let git = self.git_binary(); - let path_str = format!("HEAD:{}", path.as_unix_str()); - self.executor - .spawn(async move { - let output = git - .build_command(&["show", &path_str]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .log_err()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout).ok() - }) - .boxed() - } - fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result> { let git_binary = self.git_binary(); let oid_str = oid.to_string(); @@ -1801,6 +1768,68 @@ impl GitRepository for RealGitRepository { .boxed() } + fn load_revisions(&self, revisions: Vec) -> BoxFuture<'_, Result>>> { + let git = self.git_binary(); + self.executor + .spawn(async move { + if revisions.is_empty() { + return Ok(Vec::new()); + } + + let mut process = git + .build_command(&["cat-file", "--batch"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn()?; + + let mut stdin = BufWriter::new(process.stdin.take().context("no stdin")?); + let mut stdout = BufReader::new(process.stdout.take().context("no stdout")?); + let mut newline = [0u8; 1]; + + let mut header_bytes = Vec::new(); + let mut results = Vec::with_capacity(revisions.len()); + for rev in &revisions { + stdin.write_all(rev.as_bytes()).await?; + stdin.write_all(b"\n").await?; + stdin.flush().await?; + + header_bytes.clear(); + stdout.read_until(b'\n', &mut header_bytes).await?; + let header_line = String::from_utf8_lossy(&header_bytes); + + let parts: Vec<&str> = header_line.trim().split(' ').collect(); + match parts[..] { + [.., "missing"] => { + results.push(None); + } + [_, object_type, size_str] => { + let size: usize = size_str + .parse() + .with_context(|| format!("invalid object size: {size_str}"))?; + + let mut content = vec![0u8; size]; + stdout.read_exact(&mut content).await?; + stdout.read_exact(&mut newline).await?; + + if object_type == "blob" { + results.push(String::from_utf8(content).ok()); + } else { + results.push(None); + } + } + _ => bail!("invalid cat-file header: {header_line}"), + } + } + + drop(stdin); + process.output().await?; + Ok(results) + }) + .boxed() + } + fn merge_message(&self) -> BoxFuture<'_, Option> { let path = self.path().join("MERGE_MSG"); self.executor @@ -4709,6 +4738,103 @@ mod tests { // ); } + #[gpui::test] + async fn test_load_revisions(cx: &mut TestAppContext) { + disable_git_global_config(); + cx.executor().allow_parking(); + + let repo_dir = tempfile::tempdir().unwrap(); + git_init_repo(repo_dir.path()); + + let file1_path = repo_dir.path().join("file1"); + let file2_path = repo_dir.path().join("file2"); + let space_file_path = repo_dir.path().join("file with spaces"); + + smol::fs::write(&file1_path, "file1 committed contents") + .await + .unwrap(); + smol::fs::write(&file2_path, "file2 committed contents") + .await + .unwrap(); + smol::fs::write(&space_file_path, "space file committed contents") + .await + .unwrap(); + + let repo = RealGitRepository::new( + &repo_dir.path().join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); + + // Stage files and commit + repo.stage_paths( + vec![ + repo_path("file1"), + repo_path("file2"), + repo_path("file with spaces"), + ], + Arc::new(HashMap::default()), + ) + .await + .unwrap(); + repo.commit( + "Initial commit".into(), + None, + CommitOptions::default(), + AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), + Arc::new(test_commit_envs()), + ) + .await + .unwrap(); + + // Now modify files in index but not yet committed + smol::fs::write(&file1_path, "file1 index contents") + .await + .unwrap(); + repo.stage_paths(vec![repo_path("file1")], Arc::new(HashMap::default())) + .await + .unwrap(); + + // Write working tree contents (not indexed, not committed) + smol::fs::write(&file1_path, "file1 worktree contents") + .await + .unwrap(); + + // Now test load_revisions + let results = repo + .load_revisions( + [ + "HEAD:file1", + ":file1", + "HEAD:file2", + ":file2", + "HEAD:nonexistent", + "HEAD:file with spaces", + "HEAD:nonexistent file with spaces", + ] + .into_iter() + .map(String::from) + .collect(), + ) + .await + .unwrap(); + + assert_eq!( + results, + vec![ + Some("file1 committed contents".into()), + Some("file1 index contents".into()), + Some("file2 committed contents".into()), + Some("file2 committed contents".into()), // untouched in index, should match HEAD + None, + Some("space file committed contents".into()), + None, + ] + ); + } + #[gpui::test] async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) { disable_git_global_config(); diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 74efd3f0c86..6bf3fb85005 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -5670,26 +5670,36 @@ impl Repository { let buffer_diff_base_changes = cx .background_spawn(async move { - let mut changes = Vec::new(); - for ( - buffer, - repo_path, - is_symlink, - current_index_text, - current_head_text, - ) in &repo_diff_state_updates + let mut revisions = Vec::new(); + for (_, repo_path, is_symlink, current_index_text, current_head_text) in + &repo_diff_state_updates { - let index_text = if current_index_text.is_some() && !*is_symlink { - backend.load_index_text(repo_path.clone()) - } else { - future::ready(None).boxed() - }; - let head_text = if current_head_text.is_some() && !*is_symlink { - backend.load_committed_text(repo_path.clone()) - } else { - future::ready(None).boxed() - }; - let (index_text, head_text) = future::join(index_text, head_text).await; + if current_index_text.is_some() && !*is_symlink { + revisions.push(format!(":{}", repo_path.as_unix_str())); + } + if current_head_text.is_some() && !*is_symlink { + revisions.push(format!("HEAD:{}", repo_path.as_unix_str())); + } + } + + let mut loaded_revisions = backend + .load_revisions(revisions) + .await + .log_err() + .into_iter() + .flatten(); + + let mut changes = Vec::new(); + for (buffer, _, is_symlink, current_index_text, current_head_text) in + &repo_diff_state_updates + { + let index_text = (current_index_text.is_some() && !*is_symlink) + .then(|| loaded_revisions.next().flatten()) + .flatten(); + + let head_text = (current_head_text.is_some() && !*is_symlink) + .then(|| loaded_revisions.next().flatten()) + .flatten(); let change = match (current_index_text.as_ref(), current_head_text.as_ref()) { @@ -8996,8 +9006,13 @@ impl Repository { 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; - let staged_text = backend.load_index_text(repo_path).await; + let revisions = vec![ + format!("HEAD:{}", repo_path.as_unix_str()), + format!(":{}", repo_path.as_unix_str()), + ]; + let mut loaded_revisions = backend.load_revisions(revisions).await?.into_iter(); + let committed_text = loaded_revisions.next().flatten(); + let staged_text = loaded_revisions.next().flatten(); let diff_bases_change = if committed_text == staged_text { DiffBasesChange::SetBoth(committed_text) } else { From 3cbe4c298e78d57b88b2b308951405752d6dc3ad Mon Sep 17 00:00:00 2001 From: Dino Date: Tue, 7 Jul 2026 15:38:47 +0100 Subject: [PATCH 123/197] Add missing panels to View menu (#60356) # Objective Ensure that all existing panels have corresponding menu items in the "View" menu. I was onboarding a friend to Zed yesterday that was having a hard time figuring out how to interact with the Agent. Although he did open the "View" menu, I noticed that the Agent panel item was missing from there, making it hard for new users to discover it exists. ## Solution * Add both "Agent Panel" and "Git Panel" entries to the menu items for the "View" app menu. * Update the action used for the "Terminal Panel" menu item from `terminal_panel::ToggleFocus` to `terminal_panel::Toggle` to ensure we display a shortcut for this menu item. * Another valid option would be to update the default keymap to use `terminal_panel::ToggleFocus` instead but that would probably break existing user's expectations that the default shortcut toggles the terminal panel, instead of toggling its focus. * Introduce `zed_actions::git_panel` to be able to extract its `ToggleFocus` action, following the existing pattern. ### Next Steps It's worth noting that, even though there's now an "Agent Panel" item mapped to the `assistant::ToggleFocus` action, its default keybinding is not displayed (at least on macOS), because of the way it's defined as `cmd-?` . Using `cmd-shift-/` instead doesn't work, so we'll likely have to update `MacKeyboardMapper` to allow mapping between shifted and unshifted keys equivalent, that is, when `?` is detected, it is able to determine that, in the user's layout that is the result of `shift-/`. ## Testing Manually tested, screenshots can be seen in the "Showcase" section. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable ## Showcase
Before CleanShot 2026-07-03 at 14 24 35@2x
After CleanShot 2026-07-03 at 14 24 59@2x
--- Release Notes: - Added "Agent Panel" and "Git Panel" items to the "View" menu --- crates/git_ui/src/git_panel.rs | 6 +++--- crates/zed/src/zed/app_menus.rs | 10 +++++----- crates/zed_actions/src/lib.rs | 12 ++++++++++++ 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 590efaca2b7..d20065cc6ca 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -93,7 +93,9 @@ use workspace::{ dock::{DockPosition, Panel, PanelEvent}, notifications::{DetachAndPromptErr, NotificationId, NotifyTaskExt}, }; -use zed_actions::{DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize}; +use zed_actions::{ + DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize, git_panel::ToggleFocus, +}; const GIT_PANEL_KEY: &str = "GitPanel"; const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); @@ -110,8 +112,6 @@ actions!( Close, /// Toggles the git panel. Toggle, - /// Toggles focus on the git panel. - ToggleFocus, /// Opens the git panel menu. OpenMenu, /// Focuses on the commit message editor. diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs index 29a06747f9e..bb1f3ee7aa1 100644 --- a/crates/zed/src/zed/app_menus.rs +++ b/crates/zed/src/zed/app_menus.rs @@ -2,11 +2,9 @@ use collab_ui::collab_panel; use gpui::{App, Menu, MenuItem, OsAction}; use release_channel::ReleaseChannel; use terminal_view::terminal_panel; -use zed_actions::{debug_panel, dev}; +use zed_actions::{Quit, assistant, debug_panel, dev, git_panel, project_panel}; pub fn app_menus(cx: &mut App) -> Vec
{ - use zed_actions::Quit; - let mut view_items = vec![ MenuItem::action( "Zoom In", @@ -40,11 +38,13 @@ pub fn app_menus(cx: &mut App) -> Vec { ], }), MenuItem::separator(), - MenuItem::action("Project Panel", zed_actions::project_panel::ToggleFocus), + MenuItem::action("Project Panel", project_panel::ToggleFocus), MenuItem::action("Outline Panel", outline_panel::ToggleFocus), MenuItem::action("Collab Panel", collab_panel::ToggleFocus), - MenuItem::action("Terminal Panel", terminal_panel::ToggleFocus), + MenuItem::action("Terminal Panel", terminal_panel::Toggle), MenuItem::action("Debugger Panel", debug_panel::ToggleFocus), + MenuItem::action("Agent Panel", assistant::ToggleFocus), + MenuItem::action("Git Panel", git_panel::ToggleFocus), MenuItem::separator(), MenuItem::action("Diagnostics", diagnostics::Deploy), MenuItem::separator(), diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index c70e8d3273c..12b34def15c 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -947,3 +947,15 @@ pub mod notebook { ] ); } + +pub mod git_panel { + use gpui::actions; + + actions!( + git_panel, + [ + /// Toggles focus on the git panel. + ToggleFocus, + ] + ); +} From 5a7d414a23938c5efb674d0c2948813e37448eea Mon Sep 17 00:00:00 2001 From: Adrian Wowk Date: Tue, 7 Jul 2026 11:26:30 -0500 Subject: [PATCH 124/197] fs: Skip parent watch for poll watcher symlink targets (#57049) # Problem Since the release of the new git UI, when `~/.gitconfig` on a remote server is a symlink pointing to a file on a virtual filesystem (a common setup when using [OrbStack](https://orbstack.dev/) on macOS), Zed fails to connect with "Timed out pinging remote client". # Cause When setting up a file watcher for gitconfig, `fs::watch()` reads the symlink target and adds its parent directory to the poll watcher. `notify::PollWatcher::watch()` does a full synchronous recursive directory scan at registration time to build an initial snapshot. If the parent is something like a Mac home directory mounted via virtiofs, that scan blocks the server's main thread long enough that it can't respond to the initial ping within the 5 second timeout. # Solution The fix I implemented for this was to skip the parent directory watch when using a poll watcher. As far as I can tell, it's redundant in the poll case since the poll watcher detects changes by periodically reading metadata at the registered path, so watching the parent doesn't add anything for change detection. From my limited testing this seems to work fine but if someone with more experience in this part of the codebase would like to weigh in, that would be very much appreciated. 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 - [ ] Performance impact has been considered and is acceptable Release Notes: - Fixed remote SSH connections timing out when `~/.gitconfig` is a symlink to a file on a virtual filesystem --- crates/fs/src/fs.rs | 6 ++++- crates/fs/src/fs_watcher.rs | 50 +++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 4c4a3912263..ec75be0180e 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -1091,7 +1091,11 @@ impl Fs for RealFs { } } watcher.add(&target).ok(); - if let Some(parent) = target.parent() { + // Skipped for poll watchers: PollWatcher::watch() recursively scans + // at registration, blocking on large virtual filesystem mounts + if let Some(parent) = target.parent() + && !fs_watcher::requires_poll_watcher(parent) + { watcher.add(parent).log_err(); } } diff --git a/crates/fs/src/fs_watcher.rs b/crates/fs/src/fs_watcher.rs index 4fc66380954..016d4ca20f3 100644 --- a/crates/fs/src/fs_watcher.rs +++ b/crates/fs/src/fs_watcher.rs @@ -1245,6 +1245,56 @@ mod tests { assert_eq!(backend.unwatch_calls, &[parent.to_path_buf()]); } + #[gpui::test] + async fn pending_path_is_registered_once_created(cx: &mut gpui::TestAppContext) { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let path = temp_dir.path().join("file.txt"); + + let (tx, rx) = async_channel::unbounded(); + let pending_path_events: Arc>> = Default::default(); + let watcher = FsWatcher::new(cx.executor(), tx, pending_path_events.clone()); + + watcher + .add(&path) + .expect("add path that does not exist yet"); + assert!( + watcher + .pending_registrations + .lock() + .contains_key(path.as_path()) + ); + assert!(watcher.registrations.lock().is_empty()); + + std::fs::write(&path, b"contents").expect("create path"); + + // poll_path_until_created stats the path on smol's blocking pool, which + // the deterministic executor cannot drive; park until the poll task + // signals the event channel. + cx.executor().allow_parking(); + cx.executor().advance_clock(poll_interval()); + rx.recv().await.expect("receive watcher event"); + + assert!( + !watcher + .pending_registrations + .lock() + .contains_key(path.as_path()) + ); + let case_insensitive = case_insensitive_path(&path); + let key = WatchKey::for_registration(SanitizedPath::new(&path), case_insensitive); + assert!(watcher.registrations.lock().contains_key(&key)); + + // poll_path_until_created also enqueues a Rescan for the same path, but + // enqueue_path_events -> util::extend_sorted dedups by path, so only Created survives. + assert_eq!( + pending_path_events.lock().clone(), + vec![PathEvent { + path: path.clone(), + kind: Some(PathEventKind::Created), + }] + ); + } + #[test] fn native_watch_limit_cools_down_subsequent_native_registrations() { let native_backend = Arc::new(Mutex::new(FakeWatchBackend { From 56009f39bb916be80baace86919267cf0e09035c Mon Sep 17 00:00:00 2001 From: Philipp Schaffrath Date: Tue, 7 Jul 2026 18:52:35 +0200 Subject: [PATCH 125/197] gpui_wgpu: Fix wgpu renderer teardown panic (#60160) # Objective Opening and closing windows quickly on Linux could crash the renderer with a `GPU resources not available` panic. The `gpui_wgpu` renderer keeps its GPU resources in an `Option` that is cleared when a window is torn down, and also while device-loss recovery is pending. On Wayland the window's `Drop` releases those resources synchronously but defers unregistering the surface to a later task, so a compositor resize or transparency event can still reach the renderer in that short gap. `update_drawable_size` and `update_transparency` assumed the resources were always present and called an accessor that panics when they are not. I am not certain if there is a good case to reproduce this in zed, but I had encountered it in my own GPUI app. ## Solution Make `update_drawable_size` and `update_transparency` tolerate missing GPU resources by skipping the surface reconfiguration instead of panicking, matching how the rest of the renderer already guards this state. The requested size and alpha mode are still recorded before the guard, so they take effect if the renderer's resources are recreated, for example after device-loss recovery. ## Testing Tested on Linux with Wayland. - Reproduced by opening and closing windows quickly, which previously panicked with `GPU resources not available`. With this change the panic no longer occurs. This was reproducible on a debug build or a weak/slow device. ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes (not sure if worth mentioning): - Fixed a crash that could occur when opening and closing windows quickly on Linux Co-authored-by: Mikayla Maki --- crates/gpui_wgpu/src/wgpu_renderer.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs index 08f30dc0090..587315debfa 100644 --- a/crates/gpui_wgpu/src/wgpu_renderer.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer.rs @@ -962,7 +962,9 @@ impl WgpuRenderer { self.surface_config.height = clamped_height.max(1); let surface_config = self.surface_config.clone(); - let resources = self.resources_mut(); + let Some(resources) = self.resources.as_mut() else { + return; + }; // Wait for any in-flight GPU work to complete before destroying textures if let Err(e) = resources.device.poll(wgpu::PollType::Wait { @@ -1035,7 +1037,9 @@ impl WgpuRenderer { let surface_config = self.surface_config.clone(); let path_sample_count = self.rendering_params.path_sample_count; let dual_source_blending = self.dual_source_blending; - let resources = self.resources_mut(); + let Some(resources) = self.resources.as_mut() else { + return; + }; resources .surface .configure(&resources.device, &surface_config); From 8d932b0e0671645500b567cd474cb113085638fd Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Tue, 7 Jul 2026 10:14:37 -0700 Subject: [PATCH 126/197] git_ui: Use pull request link from push output in push toast (#60522) ## Summary Fixes #60288 and #60454 . After a push, the push toast's "Create Pull Request" button fails with `Unsupported remote URL` when the repository's remote is not a plain, recognized host. This restores the pre-#53913 behavior as a fallback: use the create-PR/MR link that `git push` itself prints, and only build a URL from the provider registry when the push output had no link. ## Why this matters #53913 made the button always appear and changed `create_pull_request` to reconstruct the PR URL from the remote via `git::parse_git_remote_url` against the `GitHostingProviderRegistry`. When the remote is not recognized, parsing returns `None` and the action errors. This affects self-hosted GitLab/GitHub, an SSH-config host alias like `git@personal:owner/repo` (the duplicate #60076), and any non-standard host. Before #53913, the flow used the link git prints in the push output, which works regardless of host, so this is a regression for anyone not pushing to a plainly-recognized GitHub URL. ## Solution `git push` prints a `remote:` line with the hosting provider's create-PR/MR URL (GitHub: "Create a pull request for '\' on GitHub by visiting:", GitLab: "To create a merge request for \, visit:", Bitbucket: "Create pull request for \:"), and we already hold the push `RemoteCommandOutput`. - `remote_output.rs`: add `extract_pull_request_url`, which scans the push stderr for the first `http(s)` URL on a `remote:` line tied to a create-PR/MR prompt. It ignores unrelated URLs (for example the OpenSSH post-quantum warning line). - `git_panel.rs`: capture that URL in `show_remote_output` into `pending_pull_request_url`, and prefer it in `create_pull_request`, falling back to the existing provider construction only when the push output had no link. The cached URL is consumed once (`take()`) and cleared when the active repo, the active branch/head, or the pending remote operation changes, so a later `git: Create Pull Request` action never opens a stale URL from an earlier push. Recognized GitHub remotes are unaffected: they still get a link from the push output, with the provider path as the fallback. ## Testing Unit tests in `remote_output.rs` cover `extract_pull_request_url` for the GitHub, GitLab, and Bitbucket prompt formats, the no-link case (returns `None`, so the provider fallback runs), and an output containing an unrelated URL that must not be mistaken for the PR link. The existing remote-operation test also asserts the cached URL is cleared when a new operation starts. `cargo test -p git_ui remote_output` passes (5 tests). Tested on macOS. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - Fixed "Create Pull Request" button in the toast shown after `git: push` failing for repositories on unrecognized Git hosts by using the link printed in the push output. - Fixed the button shown on the toast after `git: push` for GitLab branches with an existing merge request. It now shows "View Merge Request" and links to the existing merge request. --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: dino --- crates/git_ui/src/git_panel.rs | 7 ++ crates/git_ui/src/remote_output.rs | 110 ++++++++++++++++++++++++++--- 2 files changed, 109 insertions(+), 8 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index d20065cc6ca..6c0375fcbfa 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -4693,7 +4693,14 @@ impl GitPanel { .color(Color::Muted), ); match (style, is_push) { + (PushPrLink { label, url }, _) => { + this.action(label, move |_window, cx| cx.open_url(&url)) + } (Toast | ToastWithLog { .. }, true) => { + // If we were not able to parse a valid URL from the + // output of a push command, we'll simply dispatch the + // generic `CreatePullRequest` action when the toast + // button is pressed. this.action("Create Pull Request", move |window, cx| { window .dispatch_action(Box::new(zed_actions::git::CreatePullRequest), cx); diff --git a/crates/git_ui/src/remote_output.rs b/crates/git_ui/src/remote_output.rs index 157ce831677..3c9921c0f96 100644 --- a/crates/git_ui/src/remote_output.rs +++ b/crates/git_ui/src/remote_output.rs @@ -4,6 +4,17 @@ use git::repository::{Remote, RemoteCommandOutput}; use ui::SharedString; use util::ResultExt as _; +const PULL_REQUEST_HINTS: &[(&str, &str)] = &[ + // GitHub: "Create a pull request for 'branch' on GitHub by visiting:" + ("Create a pull request", "Create Pull Request"), + // Bitbucket: "Create pull request for branch:" + ("Create pull request", "Create Pull Request"), + // GitLab: "To create a merge request for branch, visit:" + ("create a merge request", "Create Merge Request"), + // GitLab: "View merge request for branch:" + ("View merge request", "View Merge Request"), +]; + #[derive(Clone)] pub enum RemoteAction { Fetch(Option), @@ -24,6 +35,7 @@ impl RemoteAction { pub enum SuccessStyle { Toast, ToastWithLog { output: RemoteCommandOutput }, + PushPrLink { label: &'static str, url: String }, } pub struct SuccessMessage { @@ -31,6 +43,42 @@ pub struct SuccessMessage { pub style: SuccessStyle, } +fn extract_pull_request_link(output: &RemoteCommandOutput) -> Option<(&'static str, String)> { + let mut pending_label: Option<&'static str> = None; + + for line in output.stderr.lines() { + let Some(remote_line) = line.trim_start().strip_prefix("remote:") else { + pending_label = None; + continue; + }; + + if let Some((_, label)) = PULL_REQUEST_HINTS + .iter() + .find(|(hint, _)| remote_line.contains(hint)) + { + pending_label = Some(label); + } + + if let Some(url) = extract_url(remote_line) + && let Some(label) = pending_label + { + return Some((label, url)); + } + } + + None +} + +fn extract_url(line: &str) -> Option { + let http_index = line.find("https://").or_else(|| line.find("http://"))?; + let url = line[http_index..] + .split_whitespace() + .next()? + .trim_end_matches(|character| matches!(character, ',' | '.' | ')' | ']' | '>')); + + Some(url.to_string()) +} + pub fn format_output(action: &RemoteAction, output: RemoteCommandOutput) -> SuccessMessage { match action { RemoteAction::Fetch(remote) => { @@ -122,6 +170,11 @@ pub fn format_output(action: &RemoteAction, output: RemoteCommandOutput) -> Succ message: "Push: Everything is up-to-date".to_string(), style: SuccessStyle::Toast, } + } else if let Some((label, url)) = extract_pull_request_link(&output) { + SuccessMessage { + message: format!("Pushed {} to {}", branch_name, remote_ref.name), + style: SuccessStyle::PushPrLink { label, url }, + } } else { SuccessMessage { message: format!("Pushed {} to {}", branch_name, remote_ref.name), @@ -161,9 +214,13 @@ mod tests { }; let msg = format_output(&action, output); - - assert!(matches!(msg.style, SuccessStyle::ToastWithLog { .. })); - assert_eq!(msg.message, "Pushed test_branch to test_remote"); + if let SuccessStyle::PushPrLink { label, url } = msg.style { + assert_eq!(msg.message, "Pushed test_branch to test_remote"); + assert_eq!(label, "Create Pull Request"); + assert_eq!(url, "https://example.com/test/test/pull/new/test"); + } else { + panic!("Expected PushPrLink variant"); + } } #[test] @@ -191,8 +248,37 @@ mod tests { let msg = format_output(&action, output); - assert!(matches!(msg.style, SuccessStyle::ToastWithLog { .. })); - assert_eq!(msg.message, "Pushed test_branch to test_remote"); + if let SuccessStyle::PushPrLink { label, url } = msg.style { + assert_eq!(msg.message, "Pushed test_branch to test_remote"); + assert_eq!(label, "Create Merge Request"); + assert_eq!( + url, + "https://example.com/test/test/-/merge_requests/new?merge_request%5Bsource_branch%5D=test" + ) + } else { + panic!("Expected PushPrLink variant") + } + } + + #[test] + fn test_push_new_branch_bitbucket_pull_request() { + let output = RemoteCommandOutput { + stdout: String::new(), + stderr: indoc! {" + remote: + remote: Create pull request for test: + remote: https://bitbucket.example.com/projects/TEST/repos/test/pull-requests?create&sourceBranch=refs/heads/test + "} + .to_string(), + }; + + assert_eq!( + extract_pull_request_link(&output), + Some(( + "Create Pull Request", + "https://bitbucket.example.com/projects/TEST/repos/test/pull-requests?create&sourceBranch=refs/heads/test".to_string() + )) + ); } #[test] @@ -206,7 +292,9 @@ mod tests { let output = RemoteCommandOutput { stdout: String::new(), - // Simulate an extraneous link that should not be found in top 3 lines + // Include an unrelated URL outside of the `remote:` lines, in this + // case, an OpenSSH warning, to ensure that it is not mistaken for + // the merge request link. stderr: indoc! {" ** WARNING: connection is not using a post-quantum key exchange algorithm. ** This session may be vulnerable to \"store now, decrypt later\" attacks. @@ -224,8 +312,13 @@ mod tests { let msg = format_output(&action, output); - assert!(matches!(msg.style, SuccessStyle::ToastWithLog { .. })); - assert_eq!(msg.message, "Pushed test_branch to test_remote"); + if let SuccessStyle::PushPrLink { label, url } = msg.style { + assert_eq!(msg.message, "Pushed test_branch to test_remote"); + assert_eq!(label, "View Merge Request"); + assert_eq!(url, "https://example.com/test/test/-/merge_requests/99999"); + } else { + panic!("Expected PushPrLink variant") + } } #[test] @@ -254,6 +347,7 @@ mod tests { output.stderr, "To http://example.com/test/test.git\n * [new branch] test -> test\n" ); + assert_eq!(extract_pull_request_link(output), None); } else { panic!("Expected ToastWithLog variant"); } From 5d7d1d3f09f1d0934a472f654a6448a366497edf Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Tue, 7 Jul 2026 17:59:03 -0400 Subject: [PATCH 127/197] language_extension: Hold LspStore weakly in LspAccess::ViaLspStore (#60558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective Fix a Windows CI flake in `extension_host extension_store_test::test_extension_store_with_test_extension`, which panicked in GPUI's leak detector with three leaked `LspStore` handles ([example run](https://github.com/zed-industries/zed/actions/runs/28871529605/job/85635418351)). ## Solution `LspAccess::ViaLspStore` held a strong `Entity`, cloned into three `ExtensionHostProxy` registrations by `language_extension::init`. The proxy sits inside an `Arc` cycle (proxy → `LanguageServerRegistryProxy` → `LanguageRegistry` → `ExtensionLspAdapter` → `WasmExtension` → `WasmHost` → proxy), so whenever an extension LSP adapter was registered at app-drop time, the cycle pinned the `LspStore` entity after its owning `Project` dropped. The flake was purely timing: extension reload toggles the adapter registration, and an unclean LSP pipe shutdown on Windows shifted teardown into the pinned window. `ViaLspStore` now holds a `WeakEntity`, upgraded at its sole use site (`remove_language_server`), skipping the stop task when the store is gone — matching the existing `ViaWorkspaces` semantics. A dead store is the expected terminal state after the owning project drops, so no error is logged or propagated. `Project`/`HeadlessProject` remain the sole long-term owners of `LspStore`, which is already the convention everywhere else (e.g. `json_schema_store`, the `lsp_store` message handlers). ## Testing - Ran `cargo nextest run -p extension_host extension_store_test::test_extension_store_with_test_extension` locally: passes. - The flake is timing-dependent (reproduced on Windows CI), so a local pass doesn't prove absence; the fix removes the only strong non-owner handle, which the leak detector reported. ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - N/A --------- Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- .../src/extension_store_test.rs | 6 ++++- .../src/extension_lsp_adapter.rs | 23 +++++++++++-------- .../src/language_extension.rs | 4 ++-- crates/remote_server/src/headless_project.rs | 2 +- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/crates/extension_host/src/extension_store_test.rs b/crates/extension_host/src/extension_store_test.rs index 59b00f936f9..aae9dbb942d 100644 --- a/crates/extension_host/src/extension_store_test.rs +++ b/crates/extension_host/src/extension_store_test.rs @@ -769,7 +769,11 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) { theme_extension::init(proxy.clone(), theme_registry.clone(), cx.executor()); let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); language_extension::init( - LspAccess::ViaLspStore(project.update(cx, |project, _| project.lsp_store())), + LspAccess::ViaLspStore( + project + .update(cx, |project, _| project.lsp_store()) + .downgrade(), + ), proxy.clone(), language_registry.clone(), ); diff --git a/crates/language_extension/src/extension_lsp_adapter.rs b/crates/language_extension/src/extension_lsp_adapter.rs index 3c28e07e6b3..44092bf2639 100644 --- a/crates/language_extension/src/extension_lsp_adapter.rs +++ b/crates/language_extension/src/extension_lsp_adapter.rs @@ -79,16 +79,19 @@ impl ExtensionLanguageServerProxy for LanguageServerRegistryProxy { let mut tasks = Vec::new(); match &self.lsp_access { - LspAccess::ViaLspStore(lsp_store) => lsp_store.update(cx, |lsp_store, cx| { - let stop_task = lsp_store.stop_language_servers_for_buffers( - Vec::new(), - HashSet::from_iter([LanguageServerSelector::Name( - language_server_name.clone(), - )]), - cx, - ); - tasks.push(stop_task); - }), + LspAccess::ViaLspStore(lsp_store) => { + if let Ok(stop_task) = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.stop_language_servers_for_buffers( + Vec::new(), + HashSet::from_iter([LanguageServerSelector::Name( + language_server_name.clone(), + )]), + cx, + ) + }) { + tasks.push(stop_task); + } + } LspAccess::ViaWorkspaces(lsp_store_provider) => { if let Ok(lsp_stores) = lsp_store_provider(cx) { for lsp_store in lsp_stores { diff --git a/crates/language_extension/src/language_extension.rs b/crates/language_extension/src/language_extension.rs index 96536b6c021..ffddd3d90f8 100644 --- a/crates/language_extension/src/language_extension.rs +++ b/crates/language_extension/src/language_extension.rs @@ -5,13 +5,13 @@ use std::sync::Arc; use anyhow::Result; use extension::{ExtensionGrammarProxy, ExtensionHostProxy, ExtensionLanguageProxy}; -use gpui::{App, Entity}; +use gpui::{App, Entity, WeakEntity}; use language::{LanguageMatcher, LanguageName, LanguageRegistry, LoadedLanguage}; use project::LspStore; #[derive(Clone)] pub enum LspAccess { - ViaLspStore(Entity), + ViaLspStore(WeakEntity), ViaWorkspaces(Arc Result>> + Send + Sync + 'static>), Noop, } diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 57f910b5ea1..db9ff6524c2 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -254,7 +254,7 @@ impl HeadlessProject { cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach(); language_extension::init( - language_extension::LspAccess::ViaLspStore(lsp_store.clone()), + language_extension::LspAccess::ViaLspStore(lsp_store.downgrade()), proxy.clone(), languages.clone(), ); From d12b980ee0721dc7fb72aeea511b1ab7b62af98c Mon Sep 17 00:00:00 2001 From: Anant Goel Date: Tue, 7 Jul 2026 15:20:34 -0700 Subject: [PATCH 128/197] bedrock: Add native support for Bedrock Mantle models (#60480) Adds native support for AWS Bedrock's Mantle endpoint (`bedrock-mantle`), which serves models with no `Converse`/`Invoke` support on `bedrock-runtime`, such as GPT-5.5, GPT-5.4, and Grok 4.3 but more importantly **open-weight** models Closes #60471 ## What's changed - Renamed the existing `Model` enum in the `bedrock` crate to `ConverseModel`, and added a new `MantleModel` enum for Mantle-only models. Mantle models reuse the existing OpenAI-compatible Chat Completions/Responses request and response plumbing (`into_open_ai`/`into_open_ai_response`, `OpenAiEventMapper`/`OpenAiResponseEventMapper`) already used by the native OpenAI and OpenAI-compatible providers, rather than introducing new marshalling code. - Added a `BedrockMantleModel` language model that routes requests to the `bedrock-mantle` endpoint, dispatching to Chat Completions or the Responses API depending on the model. Mantle models appear in the model picker alongside Converse models under the same Bedrock provider. - Added region gating: `bedrock-mantle` is only available in a subset of AWS Regions, so using a Mantle model outside of them surfaces a clear error naming the current Region and the supported ones, instead of an opaque HTTP failure. - Implemented Bedrock bearer token authentication for Mantle requests: a configured Bedrock API key is used as-is, and every other auth method (IAM credentials, named profile, SSO, automatic) derives a short-term token by locally SigV4-presigning a `CallWithBearerToken` request. This requires no extra network round trip and no token caching, since re-signing locally is cheap. - Added a specific error for the 403 you get when your credentials have `bedrock:CallWithBearerToken` but not the separate `bedrock-mantle:CallWithBearerToken` permission Mantle models require, since this is the most common misconfiguration. - Added a `mantle_available_models` setting so custom models served through `bedrock-mantle` can be configured, the same way other providers support custom models via `available_models`. - Documented Mantle models and the new setting in the Amazon Bedrock section of [Use a Gateway](https://zed.dev/docs/ai/use-a-gateway#amazon-bedrock). ## Testing - Added unit tests covering: the local SigV4 bearer-token signing (including a byte-for-byte cross-check against a reference implementation), Mantle endpoint URL construction, the Mantle-supported-regions list, thinking-effort normalization, and the settings-to-model protocol mapping. - `cargo test -p bedrock -p language_models -p settings_content -p settings` passes. - `./script/clippy` passes with no new warnings. Release Notes: - Added native support for AWS Bedrock's Mantle endpoint, enabling GPT-5.5, GPT-5.4, and Grok 4.3 through the Amazon Bedrock provider. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/bedrock/src/models.rs | 356 +++++-- crates/language_models/Cargo.toml | 1 + .../language_models/src/provider/bedrock.rs | 928 +++++++++++++++++- crates/language_models/src/settings.rs | 1 + crates/open_ai/src/completion.rs | 20 +- crates/open_ai/src/responses.rs | 17 + crates/settings_content/src/language_model.rs | 30 + docs/src/ai/use-a-gateway.md | 32 + 10 files changed, 1250 insertions(+), 137 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c0711dee737..46c2fc46e2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9811,6 +9811,7 @@ dependencies = [ "async-lock", "aws-config", "aws-credential-types", + "aws-sigv4", "aws_http_client", "base64 0.22.1", "bedrock", diff --git a/Cargo.toml b/Cargo.toml index 3611fce865b..7a7003ec5eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -543,6 +543,7 @@ aws-credential-types = { version = "1.2.8", features = [ aws-sdk-bedrockruntime = { version = "1.112.0", features = [ "behavior-version-latest", ] } +aws-sigv4 = { version = "1.4.0", features = ["http1"] } aws-smithy-runtime-api = { version = "1.9.2", features = ["http-1x", "client"] } aws-smithy-types = { version = "1.3.4", features = ["http-body-1-x"] } backtrace = "0.3" diff --git a/crates/bedrock/src/models.rs b/crates/bedrock/src/models.rs index ce5241a2dbb..cd035be8198 100644 --- a/crates/bedrock/src/models.rs +++ b/crates/bedrock/src/models.rs @@ -46,7 +46,7 @@ pub struct BedrockModelCacheConfiguration { #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)] -pub enum Model { +pub enum ConverseModel { // Anthropic Claude 4+ models #[serde( rename = "claude-fable-5", @@ -227,7 +227,7 @@ pub enum Model { }, } -impl Model { +impl ConverseModel { pub fn default_fast(_region: &str) -> Self { Self::ClaudeHaiku4_5 } @@ -817,34 +817,162 @@ impl Model { } } +/// The wire protocol used to talk to a [`MantleModel`] on the `bedrock-mantle` endpoint. +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)] +pub enum MantleProtocol { + /// The OpenAI Chat Completions API (`/chat/completions`). + #[default] + ChatCompletions, + /// The OpenAI Responses API (`/responses`). + Responses, +} + +/// Models only reachable through the `bedrock-mantle` endpoint's +/// OpenAI-compatible APIs, i.e. with no `Converse`/`Invoke` support on +/// `bedrock-runtime`. +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, EnumIter)] +pub enum MantleModel { + #[serde(rename = "gpt-5.5")] + Gpt5_5, + #[serde(rename = "gpt-5.4")] + Gpt5_4, + #[serde(rename = "grok-4.3")] + Grok4_3, + #[serde(rename = "custom")] + Custom { + name: String, + display_name: Option, + max_tokens: u64, + max_output_tokens: Option, + protocol: MantleProtocol, + supports_tools: bool, + supports_images: bool, + supports_thinking: bool, + }, +} + +impl MantleModel { + /// The model id Zed uses internally (also used as the `name` in settings). + pub fn id(&self) -> &str { + match self { + Self::Gpt5_5 => "gpt-5.5", + Self::Gpt5_4 => "gpt-5.4", + Self::Grok4_3 => "grok-4.3", + Self::Custom { name, .. } => name, + } + } + + /// The model id as expected in Bedrock Mantle request bodies, e.g. `openai.gpt-5.5`. + pub fn request_id(&self) -> &str { + match self { + Self::Gpt5_5 => "openai.gpt-5.5", + Self::Gpt5_4 => "openai.gpt-5.4", + Self::Grok4_3 => "xai.grok-4.3", + Self::Custom { name, .. } => name, + } + } + + pub fn display_name(&self) -> &str { + match self { + Self::Gpt5_5 => "GPT-5.5", + Self::Gpt5_4 => "GPT-5.4", + Self::Grok4_3 => "Grok 4.3", + Self::Custom { + display_name, name, .. + } => display_name.as_deref().unwrap_or(name.as_str()), + } + } + + /// Which OpenAI-compatible API this model must be called through. + pub fn protocol(&self) -> MantleProtocol { + match self { + Self::Gpt5_5 | Self::Gpt5_4 | Self::Grok4_3 => MantleProtocol::Responses, + Self::Custom { protocol, .. } => *protocol, + } + } + + pub fn max_token_count(&self) -> u64 { + match self { + Self::Gpt5_5 | Self::Gpt5_4 => 272_000, + Self::Grok4_3 => 1_000_000, + Self::Custom { max_tokens, .. } => *max_tokens, + } + } + + pub fn max_output_tokens(&self) -> u64 { + match self { + // AWS doesn't document a hard cap for GPT-5.5/5.4 on Mantle. + Self::Gpt5_5 | Self::Gpt5_4 => 128_000, + Self::Grok4_3 => 131_072, + Self::Custom { + max_output_tokens, .. + } => max_output_tokens.unwrap_or(4_096), + } + } + + pub fn supports_tools(&self) -> bool { + match self { + Self::Gpt5_5 | Self::Gpt5_4 | Self::Grok4_3 => true, + Self::Custom { supports_tools, .. } => *supports_tools, + } + } + + pub fn supports_images(&self) -> bool { + match self { + Self::Gpt5_5 | Self::Gpt5_4 | Self::Grok4_3 => true, + Self::Custom { + supports_images, .. + } => *supports_images, + } + } + + pub fn supports_thinking(&self) -> bool { + match self { + Self::Gpt5_5 | Self::Gpt5_4 | Self::Grok4_3 => true, + Self::Custom { + supports_thinking, .. + } => *supports_thinking, + } + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn test_builtin_mantle_models_use_responses_protocol() { + assert_eq!(MantleModel::Gpt5_5.protocol(), MantleProtocol::Responses); + assert_eq!(MantleModel::Gpt5_4.protocol(), MantleProtocol::Responses); + assert_eq!(MantleModel::Grok4_3.protocol(), MantleProtocol::Responses); + } + #[test] fn test_us_region_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("us-east-1", false)?, + ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("us-east-1", false)?, "us.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::ClaudeSonnet4.cross_region_inference_id("us-west-2", false)?, + ConverseModel::ClaudeSonnet4.cross_region_inference_id("us-west-2", false)?, "us.anthropic.claude-sonnet-4-20250514-v1:0" ); assert_eq!( - Model::ClaudeFable5.cross_region_inference_id("us-east-1", false)?, + ConverseModel::ClaudeFable5.cross_region_inference_id("us-east-1", false)?, "us.anthropic.claude-fable-5" ); assert_eq!( - Model::ClaudeSonnet5.cross_region_inference_id("us-east-1", false)?, + ConverseModel::ClaudeSonnet5.cross_region_inference_id("us-east-1", false)?, "us.anthropic.claude-sonnet-5" ); assert_eq!( - Model::NovaPro.cross_region_inference_id("us-east-2", false)?, + ConverseModel::NovaPro.cross_region_inference_id("us-east-2", false)?, "us.amazon.nova-pro-v1:0" ); assert_eq!( - Model::DeepSeekR1.cross_region_inference_id("us-east-1", false)?, + ConverseModel::DeepSeekR1.cross_region_inference_id("us-east-1", false)?, "us.deepseek.r1-v1:0" ); Ok(()) @@ -853,27 +981,27 @@ mod tests { #[test] fn test_eu_region_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::ClaudeSonnet4.cross_region_inference_id("eu-west-1", false)?, + ConverseModel::ClaudeSonnet4.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-sonnet-4-20250514-v1:0" ); assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("eu-west-1", false)?, + ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::NovaLite.cross_region_inference_id("eu-north-1", false)?, + ConverseModel::NovaLite.cross_region_inference_id("eu-north-1", false)?, "eu.amazon.nova-lite-v1:0" ); assert_eq!( - Model::ClaudeOpus4_6.cross_region_inference_id("eu-west-1", false)?, + ConverseModel::ClaudeOpus4_6.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-opus-4-6-v1" ); assert_eq!( - Model::ClaudeOpus4_7.cross_region_inference_id("eu-west-1", false)?, + ConverseModel::ClaudeOpus4_7.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-opus-4-7" ); assert_eq!( - Model::ClaudeOpus4_8.cross_region_inference_id("eu-west-1", false)?, + ConverseModel::ClaudeOpus4_8.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-opus-4-8" ); Ok(()) @@ -882,19 +1010,19 @@ mod tests { #[test] fn test_inference_profile_only_models_fall_back_to_global() -> anyhow::Result<()> { assert_eq!( - Model::ClaudeFable5.cross_region_inference_id("eu-west-1", false)?, + ConverseModel::ClaudeFable5.cross_region_inference_id("eu-west-1", false)?, "global.anthropic.claude-fable-5" ); assert_eq!( - Model::ClaudeSonnet5.cross_region_inference_id("eu-west-1", false)?, + ConverseModel::ClaudeSonnet5.cross_region_inference_id("eu-west-1", false)?, "global.anthropic.claude-sonnet-5" ); assert_eq!( - Model::ClaudeFable5.cross_region_inference_id("ap-southeast-2", false)?, + ConverseModel::ClaudeFable5.cross_region_inference_id("ap-southeast-2", false)?, "global.anthropic.claude-fable-5" ); assert_eq!( - Model::ClaudeSonnet5.cross_region_inference_id("ap-northeast-1", false)?, + ConverseModel::ClaudeSonnet5.cross_region_inference_id("ap-northeast-1", false)?, "global.anthropic.claude-sonnet-5" ); Ok(()) @@ -903,11 +1031,11 @@ mod tests { #[test] fn test_apac_region_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("ap-south-1", false)?, + ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("ap-south-1", false)?, "apac.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::NovaLite.cross_region_inference_id("ap-south-1", false)?, + ConverseModel::NovaLite.cross_region_inference_id("ap-south-1", false)?, "apac.amazon.nova-lite-v1:0" ); Ok(()) @@ -916,23 +1044,23 @@ mod tests { #[test] fn test_au_region_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::ClaudeHaiku4_5.cross_region_inference_id("ap-southeast-2", false)?, + ConverseModel::ClaudeHaiku4_5.cross_region_inference_id("ap-southeast-2", false)?, "au.anthropic.claude-haiku-4-5-20251001-v1:0" ); assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("ap-southeast-4", false)?, + ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("ap-southeast-4", false)?, "au.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::ClaudeOpus4_6.cross_region_inference_id("ap-southeast-2", false)?, + ConverseModel::ClaudeOpus4_6.cross_region_inference_id("ap-southeast-2", false)?, "au.anthropic.claude-opus-4-6-v1" ); assert_eq!( - Model::ClaudeOpus4_7.cross_region_inference_id("ap-southeast-2", false)?, + ConverseModel::ClaudeOpus4_7.cross_region_inference_id("ap-southeast-2", false)?, "au.anthropic.claude-opus-4-7" ); assert_eq!( - Model::ClaudeOpus4_8.cross_region_inference_id("ap-southeast-2", false)?, + ConverseModel::ClaudeOpus4_8.cross_region_inference_id("ap-southeast-2", false)?, "au.anthropic.claude-opus-4-8" ); Ok(()) @@ -941,15 +1069,15 @@ mod tests { #[test] fn test_jp_region_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::ClaudeHaiku4_5.cross_region_inference_id("ap-northeast-1", false)?, + ConverseModel::ClaudeHaiku4_5.cross_region_inference_id("ap-northeast-1", false)?, "jp.anthropic.claude-haiku-4-5-20251001-v1:0" ); assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("ap-northeast-3", false)?, + ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("ap-northeast-3", false)?, "jp.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::Nova2Lite.cross_region_inference_id("ap-northeast-1", false)?, + ConverseModel::Nova2Lite.cross_region_inference_id("ap-northeast-1", false)?, "jp.amazon.nova-2-lite-v1:0" ); Ok(()) @@ -958,7 +1086,7 @@ mod tests { #[test] fn test_ca_region_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::NovaLite.cross_region_inference_id("ca-central-1", false)?, + ConverseModel::NovaLite.cross_region_inference_id("ca-central-1", false)?, "ca.amazon.nova-lite-v1:0" ); Ok(()) @@ -967,11 +1095,11 @@ mod tests { #[test] fn test_gov_region_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("us-gov-east-1", false)?, + ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("us-gov-east-1", false)?, "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("us-gov-west-1", false)?, + ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("us-gov-west-1", false)?, "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0" ); Ok(()) @@ -980,45 +1108,45 @@ mod tests { #[test] fn test_global_inference_ids() -> anyhow::Result<()> { assert_eq!( - Model::ClaudeSonnet4.cross_region_inference_id("us-east-1", true)?, + ConverseModel::ClaudeSonnet4.cross_region_inference_id("us-east-1", true)?, "global.anthropic.claude-sonnet-4-20250514-v1:0" ); assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("eu-west-1", true)?, + ConverseModel::ClaudeSonnet4_5.cross_region_inference_id("eu-west-1", true)?, "global.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::ClaudeHaiku4_5.cross_region_inference_id("ap-south-1", true)?, + ConverseModel::ClaudeHaiku4_5.cross_region_inference_id("ap-south-1", true)?, "global.anthropic.claude-haiku-4-5-20251001-v1:0" ); assert_eq!( - Model::ClaudeOpus4_6.cross_region_inference_id("us-east-1", true)?, + ConverseModel::ClaudeOpus4_6.cross_region_inference_id("us-east-1", true)?, "global.anthropic.claude-opus-4-6-v1" ); assert_eq!( - Model::ClaudeOpus4_7.cross_region_inference_id("us-east-1", true)?, + ConverseModel::ClaudeOpus4_7.cross_region_inference_id("us-east-1", true)?, "global.anthropic.claude-opus-4-7" ); assert_eq!( - Model::ClaudeOpus4_8.cross_region_inference_id("us-east-1", true)?, + ConverseModel::ClaudeOpus4_8.cross_region_inference_id("us-east-1", true)?, "global.anthropic.claude-opus-4-8" ); assert_eq!( - Model::ClaudeFable5.cross_region_inference_id("us-east-1", true)?, + ConverseModel::ClaudeFable5.cross_region_inference_id("us-east-1", true)?, "global.anthropic.claude-fable-5" ); assert_eq!( - Model::ClaudeSonnet5.cross_region_inference_id("us-east-1", true)?, + ConverseModel::ClaudeSonnet5.cross_region_inference_id("us-east-1", true)?, "global.anthropic.claude-sonnet-5" ); assert_eq!( - Model::Nova2Lite.cross_region_inference_id("us-east-1", true)?, + ConverseModel::Nova2Lite.cross_region_inference_id("us-east-1", true)?, "global.amazon.nova-2-lite-v1:0" ); // Models without global support fall back to regional assert_eq!( - Model::NovaPro.cross_region_inference_id("us-east-1", true)?, + ConverseModel::NovaPro.cross_region_inference_id("us-east-1", true)?, "us.amazon.nova-pro-v1:0" ); Ok(()) @@ -1028,27 +1156,27 @@ mod tests { fn test_models_without_cross_region() -> anyhow::Result<()> { // Models without cross-region support return their request_id directly assert_eq!( - Model::Gemma3_4B.cross_region_inference_id("us-east-1", false)?, + ConverseModel::Gemma3_4B.cross_region_inference_id("us-east-1", false)?, "google.gemma-3-4b-it" ); assert_eq!( - Model::MistralLarge3.cross_region_inference_id("eu-west-1", false)?, + ConverseModel::MistralLarge3.cross_region_inference_id("eu-west-1", false)?, "mistral.mistral-large-3-675b-instruct" ); assert_eq!( - Model::Qwen3VL235B.cross_region_inference_id("ap-south-1", false)?, + ConverseModel::Qwen3VL235B.cross_region_inference_id("ap-south-1", false)?, "qwen.qwen3-vl-235b-a22b" ); assert_eq!( - Model::GptOss120B.cross_region_inference_id("us-east-1", false)?, + ConverseModel::GptOss120B.cross_region_inference_id("us-east-1", false)?, "openai.gpt-oss-120b-1:0" ); assert_eq!( - Model::MiniMaxM2.cross_region_inference_id("us-east-1", false)?, + ConverseModel::MiniMaxM2.cross_region_inference_id("us-east-1", false)?, "minimax.minimax-m2" ); assert_eq!( - Model::KimiK2Thinking.cross_region_inference_id("us-east-1", false)?, + ConverseModel::KimiK2Thinking.cross_region_inference_id("us-east-1", false)?, "moonshot.kimi-k2-thinking" ); Ok(()) @@ -1056,7 +1184,7 @@ mod tests { #[test] fn test_custom_model_inference_ids() -> anyhow::Result<()> { - let custom_model = Model::Custom { + let custom_model = ConverseModel::Custom { name: "custom.my-model-v1:0".to_string(), max_tokens: 100000, display_name: Some("My Custom Model".to_string()), @@ -1078,78 +1206,90 @@ mod tests { #[test] fn test_friendly_id_vs_request_id() { - assert_eq!(Model::ClaudeSonnet4_5.id(), "claude-sonnet-4-5"); - assert_eq!(Model::NovaLite.id(), "nova-lite"); - assert_eq!(Model::DeepSeekR1.id(), "deepseek-r1"); - assert_eq!(Model::Llama4Scout17B.id(), "llama-4-scout-17b"); - assert_eq!(Model::ClaudeFable5.id(), "claude-fable-5"); - assert_eq!(Model::ClaudeSonnet5.id(), "claude-sonnet-5"); + assert_eq!(ConverseModel::ClaudeSonnet4_5.id(), "claude-sonnet-4-5"); + assert_eq!(ConverseModel::NovaLite.id(), "nova-lite"); + assert_eq!(ConverseModel::DeepSeekR1.id(), "deepseek-r1"); + assert_eq!(ConverseModel::Llama4Scout17B.id(), "llama-4-scout-17b"); + assert_eq!(ConverseModel::ClaudeFable5.id(), "claude-fable-5"); + assert_eq!(ConverseModel::ClaudeSonnet5.id(), "claude-sonnet-5"); assert_eq!( - Model::ClaudeSonnet4_5.request_id(), + ConverseModel::ClaudeSonnet4_5.request_id(), "anthropic.claude-sonnet-4-5-20250929-v1:0" ); - assert_eq!(Model::NovaLite.request_id(), "amazon.nova-lite-v1:0"); - assert_eq!(Model::DeepSeekR1.request_id(), "deepseek.r1-v1:0"); assert_eq!( - Model::Llama4Scout17B.request_id(), + ConverseModel::NovaLite.request_id(), + "amazon.nova-lite-v1:0" + ); + assert_eq!(ConverseModel::DeepSeekR1.request_id(), "deepseek.r1-v1:0"); + assert_eq!( + ConverseModel::Llama4Scout17B.request_id(), "meta.llama4-scout-17b-instruct-v1:0" ); - assert_eq!(Model::ClaudeFable5.request_id(), "anthropic.claude-fable-5"); assert_eq!( - Model::ClaudeSonnet5.request_id(), + ConverseModel::ClaudeFable5.request_id(), + "anthropic.claude-fable-5" + ); + assert_eq!( + ConverseModel::ClaudeSonnet5.request_id(), "anthropic.claude-sonnet-5" ); // Thinking aliases deserialize to the same model - assert_eq!(Model::ClaudeSonnet4.id(), "claude-sonnet-4"); + assert_eq!(ConverseModel::ClaudeSonnet4.id(), "claude-sonnet-4"); assert_eq!( - Model::from_id("claude-sonnet-4-thinking").unwrap().id(), + ConverseModel::from_id("claude-sonnet-4-thinking") + .unwrap() + .id(), "claude-sonnet-4" ); assert_eq!( - Model::from_id("claude-fable-5-thinking").unwrap().id(), + ConverseModel::from_id("claude-fable-5-thinking") + .unwrap() + .id(), "claude-fable-5" ); assert_eq!( - Model::from_id("claude-sonnet-5-thinking").unwrap().id(), + ConverseModel::from_id("claude-sonnet-5-thinking") + .unwrap() + .id(), "claude-sonnet-5" ); } #[test] fn test_thinking_modes() { - assert!(Model::ClaudeHaiku4_5.supports_thinking()); - assert!(Model::ClaudeSonnet4.supports_thinking()); - assert!(Model::ClaudeSonnet4_5.supports_thinking()); - assert!(Model::ClaudeOpus4_6.supports_thinking()); - assert!(Model::ClaudeFable5.supports_thinking()); + assert!(ConverseModel::ClaudeHaiku4_5.supports_thinking()); + assert!(ConverseModel::ClaudeSonnet4.supports_thinking()); + assert!(ConverseModel::ClaudeSonnet4_5.supports_thinking()); + assert!(ConverseModel::ClaudeOpus4_6.supports_thinking()); + assert!(ConverseModel::ClaudeFable5.supports_thinking()); - assert!(!Model::ClaudeSonnet4.supports_adaptive_thinking()); - assert!(Model::ClaudeOpus4_6.supports_adaptive_thinking()); - assert!(Model::ClaudeSonnet4_6.supports_adaptive_thinking()); - assert!(Model::ClaudeFable5.supports_adaptive_thinking()); - assert!(Model::ClaudeSonnet5.supports_adaptive_thinking()); - assert!(!Model::ClaudeOpus4_7.supports_xhigh_adaptive_thinking()); - assert!(Model::ClaudeFable5.supports_xhigh_adaptive_thinking()); - assert!(Model::ClaudeSonnet5.supports_xhigh_adaptive_thinking()); - assert!(Model::ClaudeOpus4_8.supports_xhigh_adaptive_thinking()); + assert!(!ConverseModel::ClaudeSonnet4.supports_adaptive_thinking()); + assert!(ConverseModel::ClaudeOpus4_6.supports_adaptive_thinking()); + assert!(ConverseModel::ClaudeSonnet4_6.supports_adaptive_thinking()); + assert!(ConverseModel::ClaudeFable5.supports_adaptive_thinking()); + assert!(ConverseModel::ClaudeSonnet5.supports_adaptive_thinking()); + assert!(!ConverseModel::ClaudeOpus4_7.supports_xhigh_adaptive_thinking()); + assert!(ConverseModel::ClaudeFable5.supports_xhigh_adaptive_thinking()); + assert!(ConverseModel::ClaudeSonnet5.supports_xhigh_adaptive_thinking()); + assert!(ConverseModel::ClaudeOpus4_8.supports_xhigh_adaptive_thinking()); assert_eq!(BedrockAdaptiveThinkingEffort::XHigh.as_str(), "xhigh"); assert_eq!( - Model::ClaudeSonnet4.thinking_mode(), + ConverseModel::ClaudeSonnet4.thinking_mode(), BedrockModelMode::Thinking { budget_tokens: Some(4096) } ); assert_eq!( - Model::ClaudeOpus4_6.thinking_mode(), + ConverseModel::ClaudeOpus4_6.thinking_mode(), BedrockModelMode::AdaptiveThinking { effort: BedrockAdaptiveThinkingEffort::High } ); assert_eq!( - Model::ClaudeHaiku4_5.thinking_mode(), + ConverseModel::ClaudeHaiku4_5.thinking_mode(), BedrockModelMode::Thinking { budget_tokens: Some(4096) } @@ -1158,44 +1298,44 @@ mod tests { #[test] fn test_max_token_count() { - assert_eq!(Model::ClaudeSonnet4_5.max_token_count(), 1_000_000); - assert_eq!(Model::ClaudeOpus4_6.max_token_count(), 1_000_000); - assert_eq!(Model::ClaudeFable5.max_token_count(), 1_000_000); - assert_eq!(Model::ClaudeSonnet5.max_token_count(), 1_000_000); - assert_eq!(Model::Llama4Scout17B.max_token_count(), 128_000); - assert_eq!(Model::NovaPremier.max_token_count(), 1_000_000); + assert_eq!(ConverseModel::ClaudeSonnet4_5.max_token_count(), 1_000_000); + assert_eq!(ConverseModel::ClaudeOpus4_6.max_token_count(), 1_000_000); + assert_eq!(ConverseModel::ClaudeFable5.max_token_count(), 1_000_000); + assert_eq!(ConverseModel::ClaudeSonnet5.max_token_count(), 1_000_000); + assert_eq!(ConverseModel::Llama4Scout17B.max_token_count(), 128_000); + assert_eq!(ConverseModel::NovaPremier.max_token_count(), 1_000_000); } #[test] fn test_max_output_tokens() { - assert_eq!(Model::ClaudeSonnet4_5.max_output_tokens(), 64_000); - assert_eq!(Model::ClaudeOpus4_6.max_output_tokens(), 128_000); - assert_eq!(Model::ClaudeFable5.max_output_tokens(), 128_000); - assert_eq!(Model::ClaudeSonnet5.max_output_tokens(), 128_000); - assert_eq!(Model::ClaudeOpus4_1.max_output_tokens(), 32_000); - assert_eq!(Model::Gemma3_4B.max_output_tokens(), 8_192); + assert_eq!(ConverseModel::ClaudeSonnet4_5.max_output_tokens(), 64_000); + assert_eq!(ConverseModel::ClaudeOpus4_6.max_output_tokens(), 128_000); + assert_eq!(ConverseModel::ClaudeFable5.max_output_tokens(), 128_000); + assert_eq!(ConverseModel::ClaudeSonnet5.max_output_tokens(), 128_000); + assert_eq!(ConverseModel::ClaudeOpus4_1.max_output_tokens(), 32_000); + assert_eq!(ConverseModel::Gemma3_4B.max_output_tokens(), 8_192); } #[test] fn test_supports_tool_use() { - assert!(Model::ClaudeSonnet4_5.supports_tool_use()); - assert!(Model::ClaudeFable5.supports_tool_use()); - assert!(Model::NovaPro.supports_tool_use()); - assert!(Model::MistralLarge3.supports_tool_use()); - assert!(!Model::Gemma3_4B.supports_tool_use()); - assert!(Model::Qwen3_32B.supports_tool_use()); - assert!(Model::MiniMaxM2.supports_tool_use()); - assert!(Model::KimiK2_5.supports_tool_use()); - assert!(Model::DeepSeekR1.supports_tool_use()); - assert!(!Model::Llama4Scout17B.supports_tool_use()); + assert!(ConverseModel::ClaudeSonnet4_5.supports_tool_use()); + assert!(ConverseModel::ClaudeFable5.supports_tool_use()); + assert!(ConverseModel::NovaPro.supports_tool_use()); + assert!(ConverseModel::MistralLarge3.supports_tool_use()); + assert!(!ConverseModel::Gemma3_4B.supports_tool_use()); + assert!(ConverseModel::Qwen3_32B.supports_tool_use()); + assert!(ConverseModel::MiniMaxM2.supports_tool_use()); + assert!(ConverseModel::KimiK2_5.supports_tool_use()); + assert!(ConverseModel::DeepSeekR1.supports_tool_use()); + assert!(!ConverseModel::Llama4Scout17B.supports_tool_use()); } #[test] fn test_supports_caching() { - assert!(Model::ClaudeSonnet4_5.supports_caching()); - assert!(Model::ClaudeOpus4_6.supports_caching()); - assert!(Model::ClaudeFable5.supports_caching()); - assert!(!Model::Llama4Scout17B.supports_caching()); - assert!(!Model::NovaPro.supports_caching()); + assert!(ConverseModel::ClaudeSonnet4_5.supports_caching()); + assert!(ConverseModel::ClaudeOpus4_6.supports_caching()); + assert!(ConverseModel::ClaudeFable5.supports_caching()); + assert!(!ConverseModel::Llama4Scout17B.supports_caching()); + assert!(!ConverseModel::NovaPro.supports_caching()); } } diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index 57a0d530e45..8da9eb332ed 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -18,6 +18,7 @@ anthropic = { workspace = true, features = ["schemars"] } anyhow.workspace = true aws-config = { workspace = true, features = ["behavior-version-latest"] } aws-credential-types = { workspace = true, features = ["hardcoded-credentials"] } +aws-sigv4.workspace = true aws_http_client.workspace = true base64.workspace = true bedrock = { workspace = true, features = ["schemars"] } diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index ba3b587151a..fe12a5eb3a1 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -5,8 +5,11 @@ use anyhow::{Context as _, Result, anyhow}; use async_lock::OnceCell; use aws_config::stalled_stream_protection::StalledStreamProtectionConfig; use aws_config::{BehaviorVersion, Region}; +use aws_credential_types::provider::{ProvideCredentials, SharedCredentialsProvider}; use aws_credential_types::{Credentials, Token}; use aws_http_client::AwsHttpClient; +use aws_sigv4::http_request::{SignableBody, SignableRequest, SigningSettings, sign}; +use aws_sigv4::sign::v4; use bedrock::BedrockSystemContentBlock; use bedrock::bedrock_client::Client as BedrockClient; use bedrock::bedrock_client::config::timeout::TimeoutConfig; @@ -20,37 +23,53 @@ use bedrock::{ BedrockStreamingResponse, BedrockThinkingBlock, BedrockThinkingTextBlock, BedrockTool, BedrockToolChoice, BedrockToolConfig, BedrockToolInputSchema, BedrockToolResultBlock, BedrockToolResultContentBlock, BedrockToolResultStatus, BedrockToolSpec, BedrockToolUseBlock, - Model, value_to_aws_document, + ConverseModel, MantleModel, MantleProtocol, value_to_aws_document, }; use collections::{BTreeMap, HashMap}; use credentials_provider::CredentialsProvider; -use futures::{FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream}; +use futures::{ + AsyncBufReadExt, AsyncReadExt, FutureExt, Stream, StreamExt, future::BoxFuture, io::BufReader, + stream::BoxStream, +}; use gpui::{ App, AsyncApp, Context, Entity, FocusHandle, Subscription, Task, TaskExt, Window, actions, }; use gpui_tokio::Tokio; -use http_client::HttpClient; +use http_client::{ + AsyncBody, CustomHeaders, HttpClient, Method, Request as HttpRequest, RequestBuilderExt, + http::{HeaderValue, header::AUTHORIZATION}, +}; use language_model::{ AuthenticateError, EnvVar, IconOrSvg, InlineDescription, LanguageModel, - LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, - LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, - LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, - LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, ProviderSettingsView, - RateLimiter, Role, SubPageProviderSettings, TokenUsage, env_var, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, + LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, + LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, + LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolSchemaFormat, + LanguageModelToolUse, MessageContent, ProviderSettingsView, RateLimiter, Role, + SubPageProviderSettings, TokenUsage, env_var, }; +use open_ai::responses::Request as OpenAiResponseRequest; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; -use settings::{BedrockAvailableModel as AvailableModel, Settings, SettingsStore}; +use settings::{ + BedrockAvailableModel as AvailableModel, BedrockMantleAvailableModel as MantleAvailableModel, + Settings, SettingsStore, +}; use std::sync::LazyLock; +use std::time::SystemTime; use strum::{EnumIter, IntoEnumIterator, IntoStaticStr}; use ui::{ButtonLink, ConfiguredApiCard, Divider, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; use crate::AllLanguageModelSettings; -use http_client::CustomHeaders; +use crate::provider::open_ai::{ + ChatCompletionMaxTokensParameter, OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, + into_open_ai_response, +}; use language_model::util::{fix_streamed_json, parse_tool_arguments}; +use open_ai::{ReasoningEffort, RequestError, ResponseStreamEvent}; actions!(bedrock, [Tab, TabPrev]); @@ -116,6 +135,7 @@ impl BedrockCredentials { #[derive(Default, Clone, Debug, PartialEq)] pub struct AmazonBedrockSettings { pub available_models: Vec, + pub mantle_available_models: Vec, pub custom_headers: CustomHeaders, pub region: Option, pub endpoint: Option, @@ -151,6 +171,13 @@ impl From for BedrockAuthMethod { } } +fn mantle_protocol_from_settings(value: settings::BedrockMantleProtocolContent) -> MantleProtocol { + match value { + settings::BedrockMantleProtocolContent::ChatCompletions => MantleProtocol::ChatCompletions, + settings::BedrockMantleProtocolContent::Responses => MantleProtocol::Responses, + } +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "type", rename_all = "lowercase")] pub enum ModelMode { @@ -200,6 +227,115 @@ static ZED_BEDROCK_REGION_VAR: LazyLock = env_var!("ZED_AWS_REGION"); static ZED_AWS_ENDPOINT_VAR: LazyLock = env_var!("ZED_AWS_ENDPOINT"); static ZED_BEDROCK_BEARER_TOKEN_VAR: LazyLock = env_var!("ZED_BEDROCK_BEARER_TOKEN"); +/// AWS Regions where the `bedrock-mantle` endpoint is available. +/// See . +const MANTLE_SUPPORTED_REGIONS: &[&str] = &[ + "us-east-2", + "us-east-1", + "us-west-2", + "ap-southeast-3", + "ap-south-1", + "ap-southeast-2", + "ap-northeast-1", + "eu-central-1", + "eu-west-1", + "eu-west-2", + "eu-south-1", + "eu-north-1", + "sa-east-1", + "us-gov-west-1", +]; + +fn mantle_endpoint_url(region: &str) -> String { + format!("https://bedrock-mantle.{region}.api.aws/openai/v1") +} + +enum MantleAuth { + ApiKey { api_key: String }, + SigV4 { credentials: Credentials }, +} + +impl MantleAuth { + fn apply(&self, request: &mut HttpRequest, body: &[u8], region: &str) -> Result<()> { + match self { + MantleAuth::ApiKey { api_key } => { + let value = HeaderValue::from_str(&format!("Bearer {}", api_key.trim())) + .context("building Mantle bearer token authorization header")?; + request.headers_mut().insert(AUTHORIZATION, value); + } + MantleAuth::SigV4 { credentials } => { + sign_mantle_request_sigv4(request, body, credentials, region)?; + } + } + + Ok(()) + } +} + +fn sign_mantle_request_sigv4( + request: &mut HttpRequest, + body: &[u8], + credentials: &Credentials, + region: &str, +) -> Result<()> { + sign_mantle_request_sigv4_at(request, body, credentials, region, SystemTime::now()) +} + +fn sign_mantle_request_sigv4_at( + request: &mut HttpRequest, + body: &[u8], + credentials: &Credentials, + region: &str, + time: SystemTime, +) -> Result<()> { + if !request + .headers() + .contains_key(http_client::http::header::HOST) + && let Some(authority) = request.uri().authority() + { + let host = HeaderValue::from_str(authority.as_str()) + .context("invalid host header derived from Mantle request URI")?; + request + .headers_mut() + .insert(http_client::http::header::HOST, host); + } + + let identity = credentials.clone().into(); + let signing_params: aws_sigv4::http_request::SigningParams = v4::SigningParams::builder() + .identity(&identity) + .region(region) + .name("bedrock-mantle") + .time(time) + .settings(SigningSettings::default()) + .build() + .context("building Mantle SigV4 signing params")? + .into(); + + let method = request.method().as_str(); + let uri = request.uri().to_string(); + let headers = request + .headers() + .iter() + .map(|(name, value)| { + value + .to_str() + .map(|value| (name.as_str(), value)) + .with_context(|| format!("header {name} is not valid UTF-8 and cannot be signed")) + }) + .collect::>>()?; + + let signable_request = + SignableRequest::new(method, uri, headers.into_iter(), SignableBody::Bytes(body)) + .context("constructing Mantle SigV4 request")?; + + let (instructions, _signature) = sign(signable_request, &signing_params) + .context("signing Mantle request with SigV4")? + .into_parts(); + instructions.apply_to_request_http1x(request); + + Ok(()) +} + pub struct State { /// The resolved authentication method. Settings take priority over UX credentials. auth: Option, @@ -407,6 +543,7 @@ impl State { pub struct BedrockLanguageModelProvider { http_client: AwsHttpClient, + plain_http_client: Arc, handle: tokio::runtime::Handle, state: Entity, } @@ -428,13 +565,14 @@ impl BedrockLanguageModelProvider { }); Self { - http_client: AwsHttpClient::new(http_client), + http_client: AwsHttpClient::new(http_client.clone()), + plain_http_client: http_client, handle: Tokio::handle(cx), state, } } - fn create_language_model(&self, model: bedrock::Model) -> Arc { + fn create_language_model(&self, model: bedrock::ConverseModel) -> Arc { Arc::new(BedrockModel { id: LanguageModelId::from(model.id().to_string()), model, @@ -445,6 +583,17 @@ impl BedrockLanguageModelProvider { request_limiter: RateLimiter::new(4), }) } + + fn create_mantle_language_model(&self, model: bedrock::MantleModel) -> Arc { + Arc::new(BedrockMantleModel { + id: LanguageModelId::from(model.id().to_string()), + model, + http_client: self.plain_http_client.clone(), + state: self.state.clone(), + credentials_provider: Arc::new(OnceCell::new()), + request_limiter: RateLimiter::new(4), + }) + } } impl LanguageModelProvider for BedrockLanguageModelProvider { @@ -461,32 +610,29 @@ impl LanguageModelProvider for BedrockLanguageModelProvider { } fn default_model(&self, _cx: &App) -> Option> { - Some(self.create_language_model(bedrock::Model::default())) + Some(self.create_language_model(bedrock::ConverseModel::default())) } fn default_fast_model(&self, cx: &App) -> Option> { let region = self.state.read(cx).get_region(); - Some(self.create_language_model(bedrock::Model::default_fast(region.as_str()))) + Some(self.create_language_model(bedrock::ConverseModel::default_fast(region.as_str()))) } fn provided_models(&self, cx: &App) -> Vec> { + let bedrock_settings = &AllLanguageModelSettings::get_global(cx).bedrock; let mut models = BTreeMap::default(); - for model in bedrock::Model::iter() { - if !matches!(model, bedrock::Model::Custom { .. }) { + for model in bedrock::ConverseModel::iter() { + if !matches!(model, bedrock::ConverseModel::Custom { .. }) { models.insert(model.id().to_string(), model); } } // Override with available models from settings - for model in AllLanguageModelSettings::get_global(cx) - .bedrock - .available_models - .iter() - { + for model in bedrock_settings.available_models.iter() { models.insert( model.name.clone(), - bedrock::Model::Custom { + bedrock::ConverseModel::Custom { name: model.name.clone(), display_name: model.display_name.clone(), max_tokens: model.max_tokens, @@ -502,10 +648,43 @@ impl LanguageModelProvider for BedrockLanguageModelProvider { ); } - models + let mut models: Vec> = models .into_values() .map(|model| self.create_language_model(model)) - .collect() + .collect(); + + let mut mantle_models = BTreeMap::default(); + + for model in bedrock::MantleModel::iter() { + if !matches!(model, bedrock::MantleModel::Custom { .. }) { + mantle_models.insert(model.id().to_string(), model); + } + } + + // Override with available Mantle models from settings + for model in bedrock_settings.mantle_available_models.iter() { + mantle_models.insert( + model.name.clone(), + bedrock::MantleModel::Custom { + name: model.name.clone(), + display_name: model.display_name.clone(), + max_tokens: model.max_tokens, + max_output_tokens: model.max_output_tokens, + protocol: mantle_protocol_from_settings(model.protocol), + supports_tools: model.supports_tools.unwrap_or(false), + supports_images: model.supports_images.unwrap_or(false), + supports_thinking: model.supports_thinking.unwrap_or(false), + }, + ); + } + + models.extend( + mantle_models + .into_values() + .map(|model| self.create_mantle_language_model(model)), + ); + + models } fn is_authenticated(&self, cx: &App) -> bool { @@ -524,7 +703,7 @@ impl LanguageModelProvider for BedrockLanguageModelProvider { .into() }) .description(InlineDescription::Text( - "To use Zed's agent with Bedrock, set a custom authentication strategy in your settings or use static credentials.".into(), + "To use Zed's agent with Bedrock, set a custom authentication strategy in your settings or use static credentials. Mantle-only models (e.g. GPT-5.5, GPT-5.4, Grok 4.3) additionally require IAM permissions for the `bedrock-mantle` endpoint.".into(), )), )) } @@ -540,7 +719,7 @@ impl LanguageModelProviderState for BedrockLanguageModelProvider { struct BedrockModel { id: LanguageModelId, - model: Model, + model: ConverseModel, http_client: AwsHttpClient, handle: tokio::runtime::Handle, client: OnceCell, @@ -840,6 +1019,485 @@ impl LanguageModel for BedrockModel { } } +const MANTLE_SELECTABLE_REASONING_EFFORTS: &[ReasoningEffort] = &[ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, +]; + +fn mantle_default_reasoning_effort(model: &MantleModel) -> Option { + model.supports_thinking().then_some(ReasoningEffort::Medium) +} + +fn mantle_selected_reasoning_effort( + request: &LanguageModelRequest, + model: &MantleModel, +) -> Option { + if !model.supports_thinking() { + return None; + } + + if request.thinking_allowed { + request + .thinking_effort + .as_deref() + .and_then(|effort| effort.parse::().ok()) + .filter(|effort| *effort != ReasoningEffort::None) + .or_else(|| mantle_default_reasoning_effort(model)) + } else { + Some(ReasoningEffort::None) + } +} + +fn mantle_supported_effort_levels(model: &MantleModel) -> Vec { + let Some(default_effort) = mantle_default_reasoning_effort(model) else { + return Vec::new(); + }; + + MANTLE_SELECTABLE_REASONING_EFFORTS + .iter() + .copied() + .map(|effort| LanguageModelEffortLevel { + name: effort.label().into(), + value: effort.value().into(), + is_default: effort == default_effort, + }) + .collect() +} + +/// Special-cases Mantle authorization failures with a message that points at +/// the separate `bedrock-mantle` IAM policy namespace instead of regular +/// `bedrock-runtime` permissions. +fn map_mantle_error(model: &MantleModel, error: RequestError) -> LanguageModelCompletionError { + if let RequestError::HttpResponseError { status_code, .. } = &error + && *status_code == http_client::http::StatusCode::FORBIDDEN + { + return LanguageModelCompletionError::PermissionError { + provider: PROVIDER_NAME, + message: format!( + "Bedrock Mantle denied this request for {}. Mantle-only models require IAM \ + permissions for the `bedrock-mantle` endpoint (for example via the \ + `AmazonBedrockMantleInferenceAccess` managed policy) in addition to whatever \ + permissions your existing Bedrock credentials already have.", + model.display_name() + ), + }; + } + error.into() +} + +/// Resolves an AWS credentials provider for profile/SSO/automatic auth. +/// Cached in `cell` since building it may read config files from disk; +/// credentials themselves are still re-resolved on every call. Async so this +/// never blocks the foreground thread (unlike `BedrockModel::get_or_init_client`). +async fn resolve_mantle_credentials_provider( + cell: &OnceCell, + profile_name: Option, + region: String, +) -> Result { + let provider = cell + .get_or_try_init(move || async move { + let mut config_builder = + aws_config::defaults(BehaviorVersion::latest()).region(Region::new(region)); + + if let Some(profile_name) = profile_name.filter(|name| !name.is_empty()) { + config_builder = config_builder.profile_name(profile_name); + } + + let config = config_builder.load().await; + config + .credentials_provider() + .context("no AWS credentials provider is configured") + }) + .await + .context("resolving AWS credentials for Bedrock Mantle")?; + Ok(provider.clone()) +} + +/// Resolves provider settings into concrete Mantle request auth. A configured +/// Bedrock API key is sent as bearer auth; every AWS-credential-based method +/// signs the Mantle HTTP request directly with SigV4. +async fn resolve_mantle_auth( + credentials_provider: Arc>, + auth: Option, + region: String, +) -> Result { + match auth { + Some(BedrockAuth::ApiKey { api_key }) => Ok(MantleAuth::ApiKey { api_key }), + Some(BedrockAuth::IamCredentials { + access_key_id, + secret_access_key, + session_token, + }) => Ok(MantleAuth::SigV4 { + credentials: Credentials::new( + access_key_id, + secret_access_key, + session_token, + None, + "zed-bedrock-provider", + ), + }), + Some(BedrockAuth::NamedProfile { profile_name }) + | Some(BedrockAuth::SingleSignOn { profile_name }) => { + let provider = resolve_mantle_credentials_provider( + &credentials_provider, + Some(profile_name), + region.clone(), + ) + .await?; + let credentials = provider + .provide_credentials() + .await + .context("failed to resolve AWS credentials")?; + Ok(MantleAuth::SigV4 { credentials }) + } + Some(BedrockAuth::Automatic) | None => { + let provider = + resolve_mantle_credentials_provider(&credentials_provider, None, region.clone()) + .await?; + let credentials = provider + .provide_credentials() + .await + .context("failed to resolve AWS credentials")?; + Ok(MantleAuth::SigV4 { credentials }) + } + } +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum MantleChatStreamResult { + Ok(ResponseStreamEvent), + Err { error: MantleChatStreamError }, +} + +#[derive(Deserialize)] +struct MantleChatStreamError { + message: String, +} + +fn parse_mantle_chat_stream_line(line: &str) -> Result { + match serde_json::from_str(line) { + Ok(MantleChatStreamResult::Ok(response)) => Ok(response), + Ok(MantleChatStreamResult::Err { error }) => Err(anyhow!(error.message)), + Err(error) => { + log::error!( + "Failed to parse Mantle chat completion stream event: `{}`\nResponse: `{}`", + error, + line, + ); + Err(anyhow!(error)) + } + } +} + +fn parse_mantle_response_stream_line(line: &str) -> Result { + serde_json::from_str(line).map_err(|error| { + log::error!( + "Failed to parse Mantle responses stream event: `{}`\nResponse: `{}`", + error, + line, + ); + anyhow!(error) + }) +} + +async fn stream_mantle_sse( + client: &dyn HttpClient, + provider_name: &str, + url: &str, + region: &str, + auth: &MantleAuth, + request: Request, + extra_headers: &CustomHeaders, + parse_stream_line: fn(&str) -> Result, +) -> std::result::Result>, RequestError> +where + Request: Serialize, + Event: Send + 'static, +{ + let body = serde_json::to_vec(&request).map_err(|error| RequestError::Other(error.into()))?; + let mut request = HttpRequest::builder() + .method(Method::POST) + .uri(url) + .header("Content-Type", "application/json") + .extra_headers(extra_headers) + .body(AsyncBody::from(body.clone())) + .map_err(|error| RequestError::Other(error.into()))?; + + auth.apply(&mut request, &body, region) + .map_err(RequestError::Other)?; + + let mut response = client.send(request).await?; + if response.status().is_success() { + let reader = BufReader::new(response.into_body()); + Ok(reader + .lines() + .filter_map(move |line| async move { + match line { + Ok(line) => { + let line = line + .strip_prefix("data: ") + .or_else(|| line.strip_prefix("data:"))?; + if line == "[DONE]" || line.is_empty() { + None + } else { + Some(parse_stream_line(line)) + } + } + Err(error) => Some(Err(anyhow!(error))), + } + }) + .boxed()) + } else { + let mut body = String::new(); + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(|error| RequestError::Other(error.into()))?; + + Err(RequestError::HttpResponseError { + provider: provider_name.to_owned(), + status_code: response.status(), + body, + headers: response.headers().clone(), + }) + } +} + +fn strip_unsupported_mantle_response_fields(request: &mut OpenAiResponseRequest) { + request.context_management = None; +} + +struct BedrockMantleModel { + id: LanguageModelId, + model: MantleModel, + http_client: Arc, + state: Entity, + credentials_provider: Arc>, + request_limiter: RateLimiter, +} + +impl BedrockMantleModel { + fn stream_mantle_request( + &self, + request: Request, + cx: &AsyncApp, + endpoint: &'static str, + parse_stream_line: fn(&str) -> Result, + ) -> BoxFuture<'static, Result>, LanguageModelCompletionError>> + where + Request: Serialize + Send + 'static, + Event: Send + 'static, + { + let http_client = self.http_client.clone(); + let model = self.model.clone(); + let credentials_provider = self.credentials_provider.clone(); + let (auth, region) = cx.read_entity(&self.state, |state, _cx| { + (state.auth.clone(), state.get_region()) + }); + let url = format!("{}/{}", mantle_endpoint_url(®ion), endpoint); + let extra_headers = cx.read_entity(&self.state, |_, cx| { + AllLanguageModelSettings::get_global(cx) + .bedrock + .custom_headers + .clone() + }); + let provider_name = PROVIDER_NAME.0.to_string(); + let auth_task = Tokio::spawn_result( + cx, + resolve_mantle_auth(credentials_provider, auth, region.clone()), + ); + + let future = self.request_limiter.stream(async move { + let auth = auth_task + .await + .map_err(LanguageModelCompletionError::Other)?; + stream_mantle_sse( + http_client.as_ref(), + &provider_name, + &url, + ®ion, + &auth, + request, + &extra_headers, + parse_stream_line, + ) + .await + .map_err(|err| map_mantle_error(&model, err)) + }); + + async move { Ok(future.await?.boxed()) }.boxed() + } + + fn stream_completion( + &self, + request: open_ai::Request, + cx: &AsyncApp, + ) -> BoxFuture< + 'static, + Result>, LanguageModelCompletionError>, + > { + self.stream_mantle_request( + request, + cx, + "chat/completions", + parse_mantle_chat_stream_line, + ) + } + + fn stream_response( + &self, + request: OpenAiResponseRequest, + cx: &AsyncApp, + ) -> BoxFuture< + 'static, + Result< + BoxStream<'static, Result>, + LanguageModelCompletionError, + >, + > { + let mut request = request; + strip_unsupported_mantle_response_fields(&mut request); + self.stream_mantle_request(request, cx, "responses", parse_mantle_response_stream_line) + } +} + +impl LanguageModel for BedrockMantleModel { + fn id(&self) -> LanguageModelId { + self.id.clone() + } + + fn name(&self) -> LanguageModelName { + LanguageModelName::from(self.model.display_name().to_string()) + } + + fn provider_id(&self) -> LanguageModelProviderId { + PROVIDER_ID + } + + fn provider_name(&self) -> LanguageModelProviderName { + PROVIDER_NAME + } + + fn supports_tools(&self) -> bool { + self.model.supports_tools() + } + + fn tool_input_format(&self) -> LanguageModelToolSchemaFormat { + LanguageModelToolSchemaFormat::JsonSchemaSubset + } + + fn supports_images(&self) -> bool { + self.model.supports_images() + } + + fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { + match choice { + LanguageModelToolChoice::Auto | LanguageModelToolChoice::Any => { + self.model.supports_tools() + } + LanguageModelToolChoice::None => true, + } + } + + fn supports_streaming_tools(&self) -> bool { + true + } + + fn supports_thinking(&self) -> bool { + self.model.supports_thinking() + } + + fn supported_effort_levels(&self) -> Vec { + mantle_supported_effort_levels(&self.model) + } + + fn supports_split_token_display(&self) -> bool { + true + } + + fn telemetry_id(&self) -> String { + format!("bedrock-mantle/{}", self.model.id()) + } + + fn max_token_count(&self) -> u64 { + self.model.max_token_count() + } + + fn max_output_tokens(&self) -> Option { + Some(self.model.max_output_tokens()) + } + + fn stream_completion( + &self, + request: LanguageModelRequest, + cx: &AsyncApp, + ) -> BoxFuture< + 'static, + Result< + BoxStream<'static, Result>, + LanguageModelCompletionError, + >, + > { + let region = cx.read_entity(&self.state, |state, _cx| state.get_region()); + + if !MANTLE_SUPPORTED_REGIONS.contains(®ion.as_str()) { + let display_name = self.model.display_name().to_string(); + let supported = MANTLE_SUPPORTED_REGIONS.join(", "); + return futures::future::ready(Err(LanguageModelCompletionError::Other(anyhow!( + "{display_name} is not available in {region} because Bedrock Mantle isn't offered \ + there. Try switching to one of the following regions: {supported}." + )))) + .boxed(); + } + + let model_id = self.model.request_id().to_string(); + let max_output_tokens = Some(self.model.max_output_tokens()); + + match self.model.protocol() { + MantleProtocol::Responses => { + let request = into_open_ai_response( + request, + &model_id, + self.model.supports_tools(), + false, + max_output_tokens, + mantle_default_reasoning_effort(&self.model), + self.model.supports_thinking(), + ); + let completions = self.stream_response(request, cx); + async move { + let mapper = OpenAiResponseEventMapper::new(); + Ok(mapper.map_stream(completions.await?).boxed()) + } + .boxed() + } + MantleProtocol::ChatCompletions => { + let reasoning_effort = mantle_selected_reasoning_effort(&request, &self.model); + let request = into_open_ai( + request, + &model_id, + self.model.supports_tools(), + false, + max_output_tokens, + ChatCompletionMaxTokensParameter::MaxCompletionTokens, + reasoning_effort, + false, + ); + let completions = self.stream_completion(request, cx); + async move { + let mapper = OpenAiEventMapper::new(); + Ok(mapper.map_stream(completions.await?).boxed()) + } + .boxed() + } + } + } +} + fn deny_tool_use_events( events: impl Stream>, ) -> impl Stream> { @@ -895,7 +1553,7 @@ pub fn into_bedrock( } MessageContent::Compaction(_) => None, MessageContent::Thinking { text, signature } => { - if model.contains(Model::DeepSeekR1.request_id()) { + if model.contains(ConverseModel::DeepSeekR1.request_id()) { // DeepSeekR1 doesn't support thinking blocks // And the AWS API demands that you strip them return None; @@ -918,7 +1576,7 @@ pub fn into_bedrock( )) } MessageContent::RedactedThinking(blob) => { - if model.contains(Model::DeepSeekR1.request_id()) { + if model.contains(ConverseModel::DeepSeekR1.request_id()) { // DeepSeekR1 doesn't support thinking blocks // And the AWS API demands that you strip them return None; @@ -1834,4 +2492,218 @@ mod tests { "a cache-marked message with content should end with a cache point" ); } + + #[test] + fn test_sign_mantle_request_sigv4_uses_mantle_service() { + let credentials = Credentials::new( + "AKIDEXAMPLE", + "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + None, + None, + "test", + ); + let body = br#"{"model":"openai.gpt-5.5"}"#; + let mut request = HttpRequest::builder() + .method(Method::POST) + .uri("https://bedrock-mantle.us-east-1.api.aws/openai/v1/responses") + .header("Content-Type", "application/json") + .body(AsyncBody::from(body.to_vec())) + .unwrap(); + let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000); + + sign_mantle_request_sigv4_at(&mut request, body, &credentials, "us-east-1", time).unwrap(); + + assert_eq!( + request + .headers() + .get(http_client::http::header::HOST) + .and_then(|value| value.to_str().ok()), + Some("bedrock-mantle.us-east-1.api.aws") + ); + assert_eq!( + request + .headers() + .get("x-amz-date") + .and_then(|value| value.to_str().ok()), + Some("20231114T221320Z") + ); + let authorization = request + .headers() + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .unwrap(); + assert!(authorization.starts_with("AWS4-HMAC-SHA256 ")); + assert!( + authorization + .contains("Credential=AKIDEXAMPLE/20231114/us-east-1/bedrock-mantle/aws4_request") + ); + assert!(authorization.contains("SignedHeaders=content-type;host")); + assert!(authorization.contains("Signature=")); + } + + #[test] + fn test_mantle_endpoint_url_uses_openai_path_prefix() { + assert_eq!( + mantle_endpoint_url("us-east-1"), + "https://bedrock-mantle.us-east-1.api.aws/openai/v1" + ); + assert_eq!( + mantle_endpoint_url("us-west-2"), + "https://bedrock-mantle.us-west-2.api.aws/openai/v1" + ); + } + + #[test] + fn test_mantle_protocol_from_settings() { + assert_eq!( + mantle_protocol_from_settings(settings::BedrockMantleProtocolContent::ChatCompletions), + MantleProtocol::ChatCompletions + ); + assert_eq!( + mantle_protocol_from_settings(settings::BedrockMantleProtocolContent::Responses), + MantleProtocol::Responses + ); + } + + #[test] + fn test_mantle_supported_regions_matches_docs() { + assert!(MANTLE_SUPPORTED_REGIONS.contains(&"us-east-1")); + assert!(MANTLE_SUPPORTED_REGIONS.contains(&"eu-west-1")); + assert!(!MANTLE_SUPPORTED_REGIONS.contains(&"ap-southeast-1")); + } + + #[test] + fn test_builtin_mantle_models_support_thinking() { + assert!(MantleModel::Gpt5_5.supports_thinking()); + assert!(MantleModel::Gpt5_4.supports_thinking()); + assert!(MantleModel::Grok4_3.supports_thinking()); + assert_eq!( + mantle_default_reasoning_effort(&MantleModel::Gpt5_5), + Some(ReasoningEffort::Medium) + ); + assert_eq!( + mantle_default_reasoning_effort(&MantleModel::Grok4_3), + Some(ReasoningEffort::Medium) + ); + } + + #[test] + fn test_mantle_supported_effort_levels_hide_none() { + let effort_levels = mantle_supported_effort_levels(&MantleModel::Gpt5_5); + let values = effort_levels + .iter() + .map(|level| level.value.as_ref()) + .collect::>(); + + assert_eq!(values, ["low", "medium", "high", "xhigh"]); + assert_eq!( + effort_levels + .iter() + .find(|level| level.is_default) + .map(|level| level.value.as_ref()), + Some("medium") + ); + } + + #[test] + fn test_custom_mantle_model_can_disable_thinking() { + let model = MantleModel::Custom { + name: "custom-mantle-model".to_string(), + display_name: None, + max_tokens: 128_000, + max_output_tokens: None, + protocol: MantleProtocol::Responses, + supports_tools: true, + supports_images: false, + supports_thinking: false, + }; + + assert!(!model.supports_thinking()); + assert_eq!(mantle_default_reasoning_effort(&model), None); + assert!(mantle_supported_effort_levels(&model).is_empty()); + assert_eq!( + mantle_selected_reasoning_effort( + &LanguageModelRequest { + thinking_effort: Some("high".to_string()), + ..Default::default() + }, + &model, + ), + None + ); + } + + #[test] + fn test_disabled_mantle_thinking_serializes_none() { + let request = into_open_ai_response( + LanguageModelRequest { + thinking_allowed: false, + ..Default::default() + }, + MantleModel::Grok4_3.request_id(), + true, + false, + Some(MantleModel::Grok4_3.max_output_tokens()), + mantle_default_reasoning_effort(&MantleModel::Grok4_3), + MantleModel::Grok4_3.supports_thinking(), + ); + + assert_eq!( + serde_json::to_value(&request).unwrap()["reasoning"], + serde_json::json!({ "effort": "none" }) + ); + } + + #[test] + fn test_mantle_reasoning_passes_known_efforts_through() { + for effort in ["low", "medium", "high", "xhigh", "minimal", "max"] { + assert_eq!( + mantle_selected_reasoning_effort( + &LanguageModelRequest { + thinking_allowed: true, + thinking_effort: Some(effort.to_string()), + ..Default::default() + }, + &MantleModel::Gpt5_5, + ) + .map(|effort| effort.value()), + Some(effort) + ); + } + + assert_eq!( + mantle_selected_reasoning_effort( + &LanguageModelRequest { + thinking_allowed: true, + thinking_effort: Some("none".to_string()), + ..Default::default() + }, + &MantleModel::Gpt5_5, + ), + Some(ReasoningEffort::Medium) + ); + } + + #[test] + fn test_strip_unsupported_mantle_response_fields_removes_context_management() { + let mut request = into_open_ai_response( + LanguageModelRequest { + compact_at_tokens: Some(10_000), + ..Default::default() + }, + "openai.gpt-5.5", + true, + false, + Some(128_000), + Some(ReasoningEffort::Medium), + false, + ); + + assert!(request.context_management.is_some()); + strip_unsupported_mantle_response_fields(&mut request); + assert!(request.context_management.is_none()); + + let request = serde_json::to_value(&request).unwrap(); + assert!(request.get("context_management").is_none()); + } } diff --git a/crates/language_models/src/settings.rs b/crates/language_models/src/settings.rs index 1ce7caa3a67..4f360eea95c 100644 --- a/crates/language_models/src/settings.rs +++ b/crates/language_models/src/settings.rs @@ -95,6 +95,7 @@ impl settings::Settings for AllLanguageModelSettings { .collect(), bedrock: AmazonBedrockSettings { available_models: bedrock.available_models.unwrap_or_default(), + mantle_available_models: bedrock.mantle_available_models.unwrap_or_default(), custom_headers: custom_headers_from( "Amazon Bedrock", bedrock.custom_headers, diff --git a/crates/open_ai/src/completion.rs b/crates/open_ai/src/completion.rs index f39a9af218f..0638203d769 100644 --- a/crates/open_ai/src/completion.rs +++ b/crates/open_ai/src/completion.rs @@ -814,7 +814,8 @@ impl OpenAiResponseEventMapper { } events } - ResponsesStreamEvent::ReasoningSummaryTextDelta { delta, .. } => { + ResponsesStreamEvent::ReasoningSummaryTextDelta { delta, .. } + | ResponsesStreamEvent::ReasoningDelta { delta, .. } => { if delta.is_empty() { Vec::new() } else { @@ -973,6 +974,7 @@ impl OpenAiResponseEventMapper { | ResponsesStreamEvent::ContentPartDone { .. } | ResponsesStreamEvent::ReasoningSummaryTextDone { .. } | ResponsesStreamEvent::ReasoningSummaryPartDone { .. } + | ResponsesStreamEvent::ReasoningDone { .. } | ResponsesStreamEvent::Created { .. } | ResponsesStreamEvent::InProgress { .. } | ResponsesStreamEvent::Unknown => Vec::new(), @@ -1371,6 +1373,22 @@ mod tests { )); } + #[test] + fn responses_stream_maps_mantle_reasoning_delta() { + let event = serde_json::from_value::(json!({ + "type": "response.reasoning.delta", + "delta": "checking the contract terms" + })) + .unwrap(); + + let mapped = map_response_events(vec![event]); + assert!(matches!( + &mapped[0], + LanguageModelCompletionEvent::Thinking { text, signature: None } + if text == "checking the contract terms" + )); + } + #[test] fn response_usage_deserializes_cached_tokens() -> Result<()> { let usage: ResponseUsage = serde_json::from_value(json!({ diff --git a/crates/open_ai/src/responses.rs b/crates/open_ai/src/responses.rs index 647f9fcbace..33ec1dfba46 100644 --- a/crates/open_ai/src/responses.rs +++ b/crates/open_ai/src/responses.rs @@ -310,6 +310,23 @@ pub enum StreamEvent { output_index: usize, summary_index: usize, }, + #[serde(rename = "response.reasoning.delta")] + ReasoningDelta { + #[serde(default)] + item_id: Option, + #[serde(default)] + output_index: Option, + delta: String, + }, + #[serde(rename = "response.reasoning.done")] + ReasoningDone { + #[serde(default)] + item_id: Option, + #[serde(default)] + output_index: Option, + #[serde(default)] + text: Option, + }, #[serde(rename = "response.function_call_arguments.delta")] FunctionCallArgumentsDelta { item_id: String, diff --git a/crates/settings_content/src/language_model.rs b/crates/settings_content/src/language_model.rs index f61d80ead75..5abc65dc805 100644 --- a/crates/settings_content/src/language_model.rs +++ b/crates/settings_content/src/language_model.rs @@ -114,6 +114,10 @@ pub struct AnthropicAvailableModel { #[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)] pub struct AmazonBedrockSettingsContent { pub available_models: Option>, + /// Custom models served through the `bedrock-mantle` endpoint's + /// OpenAI-compatible APIs, in addition to the built-in Mantle models + /// (GPT-5.5, GPT-5.4, Grok 4.3). + pub mantle_available_models: Option>, pub custom_headers: Option>, pub endpoint_url: Option, pub region: Option, @@ -139,6 +143,32 @@ pub struct BedrockAvailableModel { pub mode: Option, } +#[with_fallible_options] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)] +pub struct BedrockMantleAvailableModel { + /// The model id as expected in Bedrock Mantle request bodies, e.g. `openai.gpt-5.5`. + pub name: String, + pub display_name: Option, + pub max_tokens: u64, + pub max_output_tokens: Option, + /// The OpenAI-compatible API this model must be called through. + pub protocol: BedrockMantleProtocolContent, + pub supports_tools: Option, + pub supports_images: Option, + /// Whether this custom Mantle model supports OpenAI reasoning effort parameters. + pub supports_thinking: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)] +pub enum BedrockMantleProtocolContent { + /// The OpenAI Chat Completions API (`/chat/completions`). + #[serde(rename = "chat_completions")] + ChatCompletions, + /// The OpenAI Responses API (`/responses`). + #[serde(rename = "responses")] + Responses, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)] pub enum BedrockAuthMethodContent { #[serde(rename = "named_profile")] diff --git a/docs/src/ai/use-a-gateway.md b/docs/src/ai/use-a-gateway.md index e010cfe8159..6cd1a7a614d 100644 --- a/docs/src/ai/use-a-gateway.md +++ b/docs/src/ai/use-a-gateway.md @@ -213,6 +213,38 @@ Some AWS environments require a guardrail on every Bedrock API call. Add `guardr } ``` +### Bedrock Mantle Models {#bedrock-mantle-models} + +Some models, such as GPT-5.5, GPT-5.4, and Grok 4.3, aren't available through Bedrock's Converse API and are only reachable through `bedrock-mantle`, AWS's OpenAI-compatible inference endpoint. Zed routes these models through `bedrock-mantle` automatically; they appear alongside the rest of the Bedrock models in the model picker once you're authenticated, with no extra configuration required. + +Mantle models require IAM permissions for the `bedrock-mantle` endpoint (for example via the `AmazonBedrockMantleInferenceAccess` managed policy) in addition to whatever permissions your existing Bedrock credentials already have, and `bedrock-mantle` is only available in [some AWS Regions](https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-mantle.html#regions). Zed surfaces an error naming the current Region and the supported ones if you try to use a Mantle model outside of them. + +#### Custom Bedrock Mantle Models {#bedrock-mantle-custom-models} + +You can add custom models served through `bedrock-mantle` with `mantle_available_models`: + +```json [settings] +{ + "language_models": { + "bedrock": { + "mantle_available_models": [ + { + "name": "openai.gpt-oss-120b", + "display_name": "GPT-OSS 120B", + "max_tokens": 128000, + "protocol": "chat_completions", + "supports_tools": true, + "supports_images": false, + "supports_thinking": true + } + ] + } + } +} +``` + +`protocol` selects which OpenAI-compatible API the model is called through, and must be either `chat_completions` or `responses`. Set `supports_thinking` to `true` for custom Mantle models that accept OpenAI reasoning effort parameters; Zed will then expose `low`, `medium`, `high`, and `xhigh` in the thinking effort picker, while disabling thinking sends `none`. + ## OpenAI-Compatible Gateways {#openai-compatible} If your gateway exposes an OpenAI-compatible API, configure it with [Use API Access](./use-api-access.md#openai-compatible). From f9c994796ad4341649d7b8664edbdfaae8bebd5d Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Tue, 7 Jul 2026 19:00:12 -0400 Subject: [PATCH 129/197] cloud_api_client: Make `send_authenticated_json_request` public (#60562) This PR makes the `send_authenticated_json_request` method on the `CloudApiClient` public. This way we can use it to make requests by external callers. The `build_request` method was also inlined into `send_authenticated_request` to make the contract simpler. Release Notes: - N/A --- .../cloud_api_client/src/cloud_api_client.rs | 91 +++++++++---------- 1 file changed, 43 insertions(+), 48 deletions(-) diff --git a/crates/cloud_api_client/src/cloud_api_client.rs b/crates/cloud_api_client/src/cloud_api_client.rs index e2f9d69002e..aab5f3b60da 100644 --- a/crates/cloud_api_client/src/cloud_api_client.rs +++ b/crates/cloud_api_client/src/cloud_api_client.rs @@ -95,16 +95,6 @@ impl CloudApiClient { .unwrap_or_else(|| "cloud.zed.dev".into()) } - fn build_request( - &self, - req: request::Builder, - body: impl Into, - ) -> Result, ClientApiError> { - let credentials = self.credentials.read(); - let credentials = credentials.as_ref().ok_or(ClientApiError::NotSignedIn)?; - build_request(req, body, credentials).map_err(ClientApiError::RequestBuildFailed) - } - pub async fn get_authenticated_user( &self, system_id: Option, @@ -121,8 +111,8 @@ impl CloudApiClient { builder.header(ZED_SYSTEM_ID_HEADER_NAME, system_id) }); - let request = self.build_request(request_builder, AsyncBody::default())?; - self.send_authenticated_json_request(request).await + self.send_authenticated_json_request(request_builder, AsyncBody::default()) + .await } pub fn connect(&self, cx: &App) -> Result>> { @@ -171,11 +161,11 @@ impl CloudApiClient { builder.header(ZED_SYSTEM_ID_HEADER_NAME, system_id) }); - let request = self.build_request( + self.send_authenticated_json_request( request_builder, Json(CreateLlmTokenBody { organization_id }), - )?; - self.send_authenticated_json_request(request).await + ) + .await } pub async fn update_system_settings( @@ -193,22 +183,33 @@ impl CloudApiClient { ) .header(ZED_SYSTEM_ID_HEADER_NAME, system_id); - let request = self.build_request(request_builder, Json(body))?; - self.send_authenticated_json_request(request).await + self.send_authenticated_json_request(request_builder, Json(body)) + .await } - async fn send_authenticated_json_request( + pub async fn send_authenticated_json_request( &self, - request: Request, + request_builder: request::Builder, + body: impl Into, ) -> Result { - let mut response = self.send_authenticated_request(request).await?; + let mut response = self + .send_authenticated_request(request_builder, body) + .await?; Self::read_response_json(&mut response).await } async fn send_authenticated_request( &self, - request: Request, + request_builder: request::Builder, + body: impl Into, ) -> Result, ClientApiError> { + let request = { + let credentials = self.credentials.read(); + let credentials = credentials.as_ref().ok_or(ClientApiError::NotSignedIn)?; + build_request(request_builder, body, credentials) + .map_err(ClientApiError::RequestBuildFailed)? + }; + let host = self.cloud_host(); let mut response = self.http_client.send(request).await.map_err(|source| { ClientApiError::ConnectionFailed { @@ -285,16 +286,14 @@ impl CloudApiClient { } pub async fn submit_agent_feedback(&self, body: SubmitAgentThreadFeedbackBody) -> Result<()> { - let request = self.build_request( - Request::builder().method(Method::POST).uri( - self.http_client - .build_zed_cloud_url("/client/feedback/agent_thread")? - .as_ref(), - ), - AsyncBody::from(serde_json::to_string(&body)?), - )?; + let request = Request::builder().method(Method::POST).uri( + self.http_client + .build_zed_cloud_url("/client/feedback/agent_thread")? + .as_ref(), + ); - self.send_authenticated_request(request).await?; + self.send_authenticated_request(request, AsyncBody::from(serde_json::to_string(&body)?)) + .await?; Ok(()) } @@ -302,16 +301,14 @@ impl CloudApiClient { &self, body: SubmitAgentThreadFeedbackCommentsBody, ) -> Result<()> { - let request = self.build_request( - Request::builder().method(Method::POST).uri( - self.http_client - .build_zed_cloud_url("/client/feedback/agent_thread_comments")? - .as_ref(), - ), - AsyncBody::from(serde_json::to_string(&body)?), - )?; + let request = Request::builder().method(Method::POST).uri( + self.http_client + .build_zed_cloud_url("/client/feedback/agent_thread_comments")? + .as_ref(), + ); - self.send_authenticated_request(request).await?; + self.send_authenticated_request(request, AsyncBody::from(serde_json::to_string(&body)?)) + .await?; Ok(()) } @@ -319,16 +316,14 @@ impl CloudApiClient { &self, body: SubmitEditPredictionFeedbackBody, ) -> Result<()> { - let request = self.build_request( - Request::builder().method(Method::POST).uri( - self.http_client - .build_zed_cloud_url("/client/feedback/edit_prediction")? - .as_ref(), - ), - AsyncBody::from(serde_json::to_string(&body)?), - )?; + let request = Request::builder().method(Method::POST).uri( + self.http_client + .build_zed_cloud_url("/client/feedback/edit_prediction")? + .as_ref(), + ); - self.send_authenticated_request(request).await?; + self.send_authenticated_request(request, AsyncBody::from(serde_json::to_string(&body)?)) + .await?; Ok(()) } } From 950ec7943f3f1c0532caf6b91d818fb6349c8927 Mon Sep 17 00:00:00 2001 From: Matt Good <29016+mgood@users.noreply.github.com> Date: Wed, 8 Jul 2026 02:01:58 -0700 Subject: [PATCH 130/197] editor: Decode escaped characters in hover popover links (#55973) Decodes url escape sequences in hover preview `file:///` links like escaped spaces in the file path. I'm working on an LSP and happened to be working with some files in a directory with spaces. When adding Markdown links with `file:///` the `%20` escape for spaces was being included verbatim in the path that Zed tried to open. I'm reusing the lines from `markdown_preview_view.rs` for decoding. In the existing tests I don't see coverage for `file:///` links. If you'd like some tests for this can you point me to any examples to start from? 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 decoding spaces and other escaped characters in `file://` links used in hover popovers --------- Co-authored-by: dino --- Cargo.lock | 1 + crates/editor/Cargo.toml | 1 + crates/editor/src/hover_popover.rs | 39 +++++++++++++++++++++++++++--- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 46c2fc46e2f..1d5fe8fd9bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5810,6 +5810,7 @@ dependencies = [ "unicode-width", "unindent", "url", + "urlencoding", "util", "uuid", "vim_mode_setting", diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 1ca500832e2..b6df4a370fc 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -98,6 +98,7 @@ unindent = { workspace = true, optional = true } ui.workspace = true ui_input.workspace = true url.workspace = true +urlencoding.workspace = true util.workspace = true uuid.workspace = true vim_mode_setting.workspace = true diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 96aeae19933..ff4b8c4b8d0 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -798,19 +798,33 @@ pub fn diagnostics_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { } } +fn parse_file_link(link: &str) -> Option<(PathBuf, Option)> { + let uri = Url::parse(link).ok().filter(|uri| uri.scheme() == "file")?; + let fragment = uri.fragment().map(ToOwned::to_owned); + let path = uri.to_file_path().unwrap_or_else(|_| { + let encoded = uri.path(); + + urlencoding::decode(encoded) + .map(Cow::into_owned) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(encoded)) + }); + + Some((path, fragment)) +} + pub fn open_markdown_url( workspace: Option>, link: SharedString, window: &mut Window, cx: &mut App, ) { - if let Ok(uri) = Url::parse(&link) - && uri.scheme() == "file" + if let Some((path, fragment)) = parse_file_link(&link) && let Some(workspace) = workspace { workspace.update(cx, |workspace, cx| { let task = workspace.open_abs_path( - PathBuf::from(uri.path()), + path, OpenOptions { visible: Some(OpenVisible::None), ..Default::default() @@ -823,7 +837,7 @@ pub fn open_markdown_url( let item = task.await?; // Ruby LSP uses URLs with #L1,1-4,4 // we'll just take the first number and assume it's a line number - let Some(fragment) = uri.fragment() else { + let Some(fragment) = fragment else { return anyhow::Ok(()); }; let mut accum = 0u32; @@ -2747,4 +2761,21 @@ mod tests { ); }); } + + #[test] + fn test_parse_file_links() { + assert_eq!( + parse_file_link("file:///path/to/file"), + Some((PathBuf::from("/path/to/file"), None)) + ); + assert_eq!( + parse_file_link("file:///path/to/file%20with%20spaces"), + Some((PathBuf::from("/path/to/file with spaces"), None)) + ); + assert_eq!( + parse_file_link("file:///path/to/file#123"), + Some((PathBuf::from("/path/to/file"), Some("123".to_string()))) + ); + assert_eq!(parse_file_link("http://example.com/"), None,); + } } From d4dfe87ce064d1d8eb13404decd96afe4552478b Mon Sep 17 00:00:00 2001 From: liam Date: Wed, 8 Jul 2026 07:57:02 -0400 Subject: [PATCH 131/197] workspace: Skip closed items that cannot be reopened (#56299) Resolves https://github.com/zed-industries/zed/issues/55600 This diff fixes `pane::ReopenClosedItem` getting stuck when the closed-item stack contains entries that cannot be reopened, such as Project Search, untitled buffers, or Default Settings. Previously, `Workspace::navigate_history_impl` would pop the newest closed entry and stop if that item was no longer present in the pane and had no path recorded for reopening. That made `cmd- shift-t` appear to do nothing until enough attempts had consumed those unreopenable entries. With this change, closed-item navigation keeps scanning when it encounters an entry that cannot be activated or reopened by path. This preserves the current path-based reopening behavior for normal files, while avoiding no-op shortcuts caused by non-file items in the closed stack. This made me wonder whether or not we'd eventually want full reopen support for non-traditional items like Project Search or bundled settings editors. Supporting that properly would require storing item-specific restoration state, such as search query/options for Project Search or a bundled-file descriptor for Default Settings, and teaching closed-item navigation how to recreate those items from that state. Something definitely out of scope for this PR. | Before | After | | --- | --- | | | | Release Notes: - Fixed reopening closed tabs getting stuck on closed items that cannot be reopened. --- crates/workspace/src/workspace.rs | 87 +++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 161f65974c4..dd88d767413 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -2823,10 +2823,11 @@ impl Workspace { } else { // If the item is no longer present in this pane, then retrieve its // path info in order to reopen it. - break pane - .nav_history() - .path_for_item(entry.item.id()) - .map(|(project_path, abs_path)| (project_path, abs_path, entry)); + if let Some((project_path, abs_path)) = + pane.nav_history().path_for_item(entry.item.id()) + { + break Some((project_path, abs_path, entry)); + } } } }) @@ -15068,6 +15069,84 @@ mod tests { ); } + #[gpui::test] + async fn test_reopen_closed_item_skips_items_without_paths(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + + let project = Project::test(fs, [], cx).await; + + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + let reopenable_item = cx.new(TestItem::new); + + let active_item = cx.new(TestItem::new); + let unreopenable_item = cx.new(TestItem::new); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane( + Box::new(reopenable_item.clone()), + None, + true, + window, + cx, + ); + workspace.add_item_to_active_pane( + Box::new(active_item.clone()), + None, + true, + window, + cx, + ); + }); + + pane.update(cx, |pane, _| { + pane.nav_history_mut().set_mode(NavigationMode::ClosingItem); + }); + + reopenable_item.update_in(cx, |item, window, cx| { + item.deactivated(window, cx); + }); + + pane.update(cx, |pane, _| { + pane.nav_history_mut().set_mode(NavigationMode::Normal); + }); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane( + Box::new(unreopenable_item.clone()), + None, + true, + window, + cx, + ); + }); + + pane.update_in(cx, |pane, window, cx| { + pane.close_item_by_id(unreopenable_item.item_id(), SaveIntent::Skip, window, cx) + .detach_and_log_err(cx); + }); + + cx.run_until_parked(); + + workspace + .update_in(cx, |workspace, window, cx| { + workspace.reopen_closed_item(window, cx) + }) + .await + .unwrap(); + + pane.read_with(cx, |pane, _| { + assert_eq!( + pane.active_item().unwrap().item_id(), + reopenable_item.item_id() + ); + }); + } + #[gpui::test] async fn test_no_save_prompt_when_dirty_multi_buffer_closed_with_all_of_its_dirty_items_present_in_the_pane( cx: &mut TestAppContext, From 01235732e683e524349585c06f290a4803806c48 Mon Sep 17 00:00:00 2001 From: Rain Date: Wed, 8 Jul 2026 05:08:36 -0700 Subject: [PATCH 132/197] Fix should_log_lsp_request_failure logic inversion (#57554) Was trying to debug an r-a issue and ran into this. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliabilityx - [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: - N/A or Added/Fixed/Improved ... Co-authored-by: Kirill Bulatov --- crates/project/src/lsp_store.rs | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 96f02fcb778..2a95034cbaa 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -4225,9 +4225,10 @@ impl SymbolLocation { } fn should_log_lsp_request_failure(message: &str) -> bool { - // content modified is a weird failure mode of rust-analyzer - // where requests are denied before its loaded a project - message.ends_with("content modified") || message.ends_with("server cancelled the request") + // "content modified" and "server cancelled the request" are noisy failure + // modes of rust-analyzer where requests are denied before it has loaded a + // project. + !(message.ends_with("content modified") || message.ends_with("server cancelled the request")) } impl LspStore { @@ -15312,3 +15313,28 @@ fn extend_formatting_transaction( Ok(()) }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_log_lsp_request_failure_suppresses_known_noise() { + // Suppressed: rust-analyzer's superseded/denied request signals. + assert!(!should_log_lsp_request_failure( + "Get diagnostics via rust-analyzer failed: content modified" + )); + assert!(!should_log_lsp_request_failure( + "Get diagnostics via rust-analyzer failed: server cancelled the request" + )); + + // Logged: anything else is a real failure. + assert!(should_log_lsp_request_failure( + "Get diagnostics via rust-analyzer failed: server shut down" + )); + assert!(should_log_lsp_request_failure( + "Get diagnostics via rust-analyzer failed: Server reset the connection" + )); + assert!(should_log_lsp_request_failure("something else entirely")); + } +} From bc3c9422f4b29cdafd64fc89853b8705a359a0e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Raz=20Guzm=C3=A1n=20Macedo?= Date: Wed, 8 Jul 2026 06:10:32 -0600 Subject: [PATCH 133/197] Use more .array_windows::() (#58877) - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable The fight against entropy continues... Release Notes: - N/A or Added/Fixed/Improved ... --- crates/editor/src/input.rs | 6 ++---- crates/file_finder/src/file_finder.rs | 2 +- crates/markdown/src/html/html_rendering.rs | 4 +--- crates/outline_panel/src/outline_panel.rs | 6 ++---- crates/zeta_prompt/src/multi_region.rs | 9 +++------ 5 files changed, 9 insertions(+), 18 deletions(-) diff --git a/crates/editor/src/input.rs b/crates/editor/src/input.rs index 876bb8fa286..8192fc4a18f 100644 --- a/crates/editor/src/input.rs +++ b/crates/editor/src/input.rs @@ -1666,10 +1666,8 @@ impl Editor { this.change_selections(Default::default(), window, cx, |s| s.select(selections)); let selections = this.selections.all::(&this.display_snapshot(cx)); - let selections_on_single_row = selections.windows(2).all(|selections| { - selections[0].start.row == selections[1].start.row - && selections[0].end.row == selections[1].end.row - && selections[0].start.row == selections[0].end.row + let selections_on_single_row = selections.array_windows::<2>().all(|[a, b]| { + a.start.row == b.start.row && a.end.row == b.end.row && a.start.row == a.end.row }); let selections_selecting = selections .iter() diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index f3711361d13..1203e3dde02 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1952,7 +1952,7 @@ impl<'a> PathComponentSlice<'a> { fn elision_range(&self, budget: usize, matches: &[usize]) -> Option> { let eligible_range = { - assert!(matches.windows(2).all(|w| w[0] <= w[1])); + assert!(matches.is_sorted()); let mut matches = matches.iter().copied().peekable(); let mut longest: Option> = None; let mut cur = 0..0; diff --git a/crates/markdown/src/html/html_rendering.rs b/crates/markdown/src/html/html_rendering.rs index 25e869625fb..00b73f1e44e 100644 --- a/crates/markdown/src/html/html_rendering.rs +++ b/crates/markdown/src/html/html_rendering.rs @@ -391,9 +391,7 @@ impl MarkdownElement { boundaries.sort_unstable(); boundaries.dedup(); - for segment in boundaries.windows(2) { - let start = segment[0]; - let end = segment[1]; + for &[start, end] in boundaries.array_windows::<2>() { if start >= end { continue; } diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 7c3845eb98f..c880891502b 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -3447,10 +3447,8 @@ impl OutlinePanel { }; let fetched_outlines = outline_task.await; let outlines_with_children = fetched_outlines - .windows(2) - .filter_map(|window| { - let current = &window[0]; - let next = &window[1]; + .array_windows::<2>() + .filter_map(|[current, next]| { if next.depth > current.depth { Some((current.range.clone(), current.depth)) } else { diff --git a/crates/zeta_prompt/src/multi_region.rs b/crates/zeta_prompt/src/multi_region.rs index 540bc2db11a..0cecb38decf 100644 --- a/crates/zeta_prompt/src/multi_region.rs +++ b/crates/zeta_prompt/src/multi_region.rs @@ -582,8 +582,8 @@ pub fn nearest_marker_number(cursor_offset: Option, marker_offsets: &[usi fn cursor_block_index(cursor_offset: Option, marker_offsets: &[usize]) -> usize { let cursor = cursor_offset.unwrap_or(0); marker_offsets - .windows(2) - .position(|window| cursor >= window[0] && cursor < window[1]) + .array_windows::<2>() + .position(|&[a, b]| cursor >= a && cursor < b) .unwrap_or_else(|| marker_offsets.len().saturating_sub(2)) } @@ -1220,10 +1220,7 @@ hhhhhhhhhh = 8; Some(text.len()), "offsets: {offsets:?}" ); - assert!( - offsets.windows(2).all(|window| window[0] <= window[1]), - "offsets must be sorted: {offsets:?}" - ); + assert!(offsets.is_sorted(), "offsets must be sorted: {offsets:?}"); } #[test] From 2ab35c6b7d594288cdfee6695d1afa8f7ce91444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Houl=C3=A9?= <13155277+tomhoule@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:20:52 +0200 Subject: [PATCH 134/197] Consistently use `context()` to preserve sources for anyhow errors (#59112) When you use `anyhow::anyhow!("{error}")` to convert a preexisting error to an `anyhow::Error`, the error source can be lost (depending on the `Display` impl of the error). Anyhow errors can display the whole source chain when printed. This commit makes us consistently use `context()` instead to preserve the underlying error's source. Release Notes: - N/A --- crates/agent/src/db.rs | 4 ++-- crates/keymap_editor/src/keymap_editor.rs | 2 +- crates/remote/src/transport/ssh.rs | 4 +++- crates/remote/src/transport/wsl.rs | 8 +++++--- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index 7d69e03c325..a09aa9c2c13 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -2,7 +2,7 @@ use crate::{AgentMessage, AgentMessageContent, UserMessage, UserMessageContent}; use acp_thread::ClientUserMessageId; use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentProfileId; -use anyhow::{Result, anyhow}; +use anyhow::Result; use chrono::{DateTime, Utc}; use collections::{HashMap, IndexMap}; use futures::{FutureExt, future::Shared}; @@ -444,7 +444,7 @@ impl ThreadsDatabase { data BLOB NOT NULL ) "})?() - .map_err(|e| anyhow!("Failed to create threads table: {}", e))?; + .map_err(|e| e.context("Failed to create threads table"))?; if let Ok(mut s) = connection.exec(indoc! {" ALTER TABLE threads ADD COLUMN parent_id TEXT diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index aecaea7fcba..db405135994 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -3668,7 +3668,7 @@ async fn save_keybinding_update( keyboard_mapper, deprecated_aliases, ) - .map_err(|err| anyhow::anyhow!("Could not save updated keybinding: {}", err))?; + .map_err(|err| err.context("Could not save updated keybinding"))?; fs.write( paths::keymap_file().as_path(), updated_keymap_contents.as_bytes(), diff --git a/crates/remote/src/transport/ssh.rs b/crates/remote/src/transport/ssh.rs index 25946c0b7c0..f897bdd1d96 100644 --- a/crates/remote/src/transport/ssh.rs +++ b/crates/remote/src/transport/ssh.rs @@ -496,7 +496,9 @@ impl RemoteConnection for SshRemoteConnection { { Ok(process) => process, Err(error) => { - return Task::ready(Err(anyhow!("failed to spawn remote server: {}", error))); + return Task::ready(Err( + anyhow::Error::new(error).context("failed to spawn remote server") + )); } }; diff --git a/crates/remote/src/transport/wsl.rs b/crates/remote/src/transport/wsl.rs index 21ebdcbd049..9b8e7763944 100644 --- a/crates/remote/src/transport/wsl.rs +++ b/crates/remote/src/transport/wsl.rs @@ -210,7 +210,7 @@ impl WslRemoteConnection { let mkdir = self.shell_kind.prepend_command_prefix("mkdir"); self.run_wsl_command(&mkdir, &["-p", &parent]) .await - .map_err(|e| anyhow!("Failed to create directory: {}", e))?; + .map_err(|e| e.context("Failed to create directory"))?; } let binary_exists_on_server = self @@ -346,7 +346,7 @@ impl WslRemoteConnection { self.run_wsl_command("sh", &["-c", &script]) .await - .map_err(|e| anyhow!("Failed to extract server binary: {}", e))?; + .map_err(|e| e.context("Failed to extract server binary"))?; Ok(()) } } @@ -395,7 +395,9 @@ impl RemoteConnection for WslRemoteConnection { { Ok(process) => process, Err(error) => { - return Task::ready(Err(anyhow!("failed to spawn remote server: {}", error))); + return Task::ready(Err( + anyhow::Error::new(error).context("failed to spawn remote server") + )); } }; From 2243c13b9b224311fdb64362b2310c364f7dfbbb Mon Sep 17 00:00:00 2001 From: Ibrahim Khan <2005ibrahimkhan@gmail.com> Date: Wed, 8 Jul 2026 05:21:10 -0700 Subject: [PATCH 135/197] project: Fix content swap when an LSP rename also renames the file (#59104) A "rename symbol" whose workspace edit also renames the file (a `TextDocumentEdit` followed by a `RenameFile` resource operation) only applied the text edit to the in-memory buffer. The on-disk file still held the pre-edit content, so the blind `fs.rename` moved stale bytes to the new path while the edited buffer was stranded at the old path, swapping the two files' contents. This persists a dirty buffer for the rename source before renaming, so the new file receives the edited content and the now-clean buffer can't be saved back to the old path. 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 adheres to Zed's UI standards - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #59077 Release Notes: - Fixed a symbol rename that also renames the file swapping the contents of the old and new files --- crates/project/src/lsp_store.rs | 19 ++++ .../tests/integration/project_tests.rs | 104 ++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 2a95034cbaa..1555a25dd21 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -3440,6 +3440,25 @@ impl LocalLspStore { .to_file_path() .map_err(|()| anyhow!("can't convert URI to path"))?; + // An LSP "rename symbol" can also rename the file, with the text edit + // applied only to the in-memory buffer. Persist it before renaming, or + // fs.rename moves the stale on-disk content and the files' contents swap. + let dirty_buffer = this.update(cx, |this, cx| { + let project_path = this + .worktree_store() + .read(cx) + .project_path_for_absolute_path(&source_abs_path, cx)?; + let buffer = this.buffer_store().read(cx).get_by_path(&project_path)?; + buffer.read(cx).is_dirty().then_some(buffer) + }); + if let Some(buffer) = dirty_buffer { + this.update(cx, |this, cx| { + this.buffer_store() + .update(cx, |buffer_store, cx| buffer_store.save_buffer(buffer, cx)) + }) + .await?; + } + let options = fs::RenameOptions { overwrite: op .options diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index 7b4f5839e94..47ff6c07409 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -7673,6 +7673,110 @@ async fn test_rename(cx: &mut gpui::TestAppContext) { ); } +// Regression test for https://github.com/zed-industries/zed/issues/59077: +// a "rename symbol" whose workspace edit also renames the file used to swap the +// two files' contents. The edited content must end up in the renamed file. +#[gpui::test] +async fn test_rename_that_also_renames_file(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "one.rs": "const ONE: usize = 1;", + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + rename_provider: Some(lsp::OneOf::Right(lsp::RenameOptions { + prepare_provider: Some(true), + work_done_progress_options: Default::default(), + })), + ..Default::default() + }, + ..Default::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/one.rs"), cx) + }) + .await + .unwrap(); + + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let response = project.update(cx, |project, cx| { + project.perform_rename(buffer.clone(), 7, "THREE".to_string(), cx) + }); + fake_server + .set_request_handler::(|_params, _| async move { + Ok(Some(lsp::WorkspaceEdit { + changes: None, + document_changes: Some(DocumentChanges::Operations(vec![ + lsp::DocumentChangeOperation::Edit(lsp::TextDocumentEdit { + text_document: lsp::OptionalVersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/dir/one.rs")).unwrap(), + version: None, + }, + edits: vec![lsp::Edit::Plain(lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(0, 6), lsp::Position::new(0, 9)), + "THREE".to_string(), + ))], + }), + lsp::DocumentChangeOperation::Op(lsp::ResourceOp::Rename(lsp::RenameFile { + old_uri: Uri::from_file_path(path!("/dir/one.rs")).unwrap(), + new_uri: Uri::from_file_path(path!("/dir/three.rs")).unwrap(), + options: None, + annotation_id: None, + })), + ])), + change_annotations: None, + })) + }) + .next() + .await + .unwrap(); + response.await.unwrap(); + cx.executor().run_until_parked(); + + // The renamed file must contain the edited content, not the stale pre-edit + // content, and the old file must be gone (no content swap). + assert_eq!( + fs.load(Path::new(path!("/dir/three.rs"))).await.unwrap(), + "const THREE: usize = 1;" + ); + assert!( + fs.load(Path::new(path!("/dir/one.rs"))).await.is_err(), + "old file should not exist after the rename" + ); + + // The buffer should follow the rename to the new path, hold the edited + // content, and be saved (so it can't be written back to the old path). + buffer.read_with(cx, |buffer, _cx| { + assert_eq!(buffer.text(), "const THREE: usize = 1;"); + assert_eq!( + buffer.file().unwrap().path().as_ref(), + rel_path("three.rs"), + "buffer should be associated with the renamed file" + ); + assert!( + !buffer.is_dirty(), + "buffer should be saved after the rename" + ); + }); +} + #[gpui::test] async fn test_search(cx: &mut gpui::TestAppContext) { init_test(cx); From 35ddcb2ecd07d38b1579c5199f175d0e9c2133f4 Mon Sep 17 00:00:00 2001 From: olegator888 <139753116+olegator888@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:21:46 +0300 Subject: [PATCH 136/197] go: Fix outline for methods with unnamed receivers (#58656) 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 This fixes Go outline extraction for methods whose receiver has no name, such as `func (v2) Method()`. These methods are now included in outline symbols, which also feed breadcrumbs and sticky scroll. Tested with: - `cargo test -p languages` - `cargo test -p grammars` - `./script/clippy -p languages` - `cargo fmt --check --package languages` Before: [before_cut.webm](https://github.com/user-attachments/assets/91eb5cb0-703a-4496-b0dd-5369c4c219fc) After: [after_cut.webm](https://github.com/user-attachments/assets/76d13d88-3671-4118-99fc-c073a6e64727) Release Notes: - Fixed Go methods with unnamed receivers not appearing in breadcrumbs and sticky scroll. Co-authored-by: Kirill Bulatov --- crates/grammars/src/go/outline.scm | 2 +- crates/languages/src/go.rs | 84 ++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/crates/grammars/src/go/outline.scm b/crates/grammars/src/go/outline.scm index da42904fab9..29b9bd7554a 100644 --- a/crates/grammars/src/go/outline.scm +++ b/crates/grammars/src/go/outline.scm @@ -23,7 +23,7 @@ receiver: (parameter_list "(" @context (parameter_declaration - name: (_) @context + name: (_)? @context type: (_) @context) ")" @context) name: (field_identifier) @name diff --git a/crates/languages/src/go.rs b/crates/languages/src/go.rs index 13a4fcc9bdf..87efea16c85 100644 --- a/crates/languages/src/go.rs +++ b/crates/languages/src/go.rs @@ -945,6 +945,7 @@ mod tests { use gpui::{AppContext, Hsla, TestAppContext}; use task::TaskContext; use theme::SyntaxTheme; + use unindent::Unindent as _; fn go_language() -> Arc { let language = language("go", tree_sitter_go::LANGUAGE.into()); @@ -2017,6 +2018,89 @@ mod tests { ); } + #[gpui::test] + fn test_go_outline_includes_methods_with_receiver_forms(cx: &mut TestAppContext) { + let language = go_language(); + + let source = r#" + package main + + type v2 struct{} + + func (v2) BrokenMethod() { + println("start") + } + + func (_ v2) UnderscoreReceiverMethod() { + println("start") + } + + func (v v2) NamedReceiverMethod() { + println("start") + } + + func (v *v2) PointerReceiverMethod() { + println("start") + } + + func WorkingFunction() { + println("start") + } + "# + .unindent(); + + let buffer = + cx.new(|cx| crate::Buffer::local(source.clone(), cx).with_language(language, cx)); + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + let outline = snapshot.outline(None); + + assert_eq!( + outline + .items + .iter() + .map(|item| item.text.as_str()) + .collect::>(), + &[ + "type v2", + "func (v2) BrokenMethod", + "func (_ v2) UnderscoreReceiverMethod", + "func (v v2) NamedReceiverMethod", + "func (v *v2) PointerReceiverMethod", + "func WorkingFunction", + ] + ); + + for (method_name, expected_symbol) in [ + ("BrokenMethod", "func (v2) BrokenMethod"), + ( + "UnderscoreReceiverMethod", + "func (_ v2) UnderscoreReceiverMethod", + ), + ("NamedReceiverMethod", "func (v v2) NamedReceiverMethod"), + ( + "PointerReceiverMethod", + "func (v *v2) PointerReceiverMethod", + ), + ("WorkingFunction", "func WorkingFunction"), + ] { + let method_position = source + .find(&format!("{method_name}()")) + .expect("method should exist in source"); + let body_position = source[method_position..] + .find("println") + .map(|body_offset| method_position + body_offset) + .expect("method should contain a body"); + let symbols = snapshot.symbols_containing(body_position, None); + assert_eq!( + symbols + .last() + .map(|item| item.text.as_str()) + .expect("method should have an outline symbol"), + expected_symbol + ); + } + } + #[test] fn test_extract_subtest_name() { // Interpreted string literal From 385e9c68ae4a068361417ec1aa55e5f727f615fa Mon Sep 17 00:00:00 2001 From: Guilherme Tavares <95603984+tavaresgmg@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:29:04 -0300 Subject: [PATCH 137/197] Suggest language extensions for untitled buffers (#55263) Fixes #53527. ## Summary - Suggest `untitled.` when saving an untitled editor buffer with a selected non-Plain Text language. - Preserve the existing title-based suggestion for existing files, Plain Text buffers, and buffers without a language extension. - Add a regression test for an untitled Rust buffer suggesting `untitled.rs`. ## Testing - `mise exec rust@1.95.0 -- cargo fmt --check -p editor` - `mise exec rust@1.95.0 -- cargo test -p editor test_suggested_filename_uses_language_extension_for_untitled_buffer --lib` ## Suggested .rules additions None. Release Notes: - Fixed Save As suggestions for untitled buffers with a selected language. --------- Co-authored-by: Kirill Bulatov --- crates/acp_thread/src/diff.rs | 4 +- crates/agent_ui/src/diagnostics.rs | 3 +- .../src/rate_prediction_modal.rs | 9 +- crates/editor/src/edit_prediction.rs | 4 +- crates/editor/src/editor.rs | 2 +- crates/editor/src/element/header.rs | 4 +- crates/editor/src/items.rs | 110 +++++++++++++++++- crates/git_ui/src/file_diff_view.rs | 4 +- crates/git_ui/src/multi_diff_view.rs | 4 +- crates/git_ui/src/text_diff_view.rs | 2 +- crates/multi_buffer/src/multi_buffer.rs | 5 +- 11 files changed, 129 insertions(+), 22 deletions(-) diff --git a/crates/acp_thread/src/diff.rs b/crates/acp_thread/src/diff.rs index d297b5fa98f..0834d7eabba 100644 --- a/crates/acp_thread/src/diff.rs +++ b/crates/acp_thread/src/diff.rs @@ -177,7 +177,7 @@ impl Diff { }; format!( "Diff: {}\n```\n{}\n```\n", - path.unwrap_or("untitled".into()), + path.unwrap_or(MultiBuffer::DEFAULT_TITLE.into()), buffer_text ) } @@ -260,7 +260,7 @@ impl PendingDiff { let path = new_buffer .file() .map(|file| file.path().display(file.path_style(cx))) - .unwrap_or("untitled".into()) + .unwrap_or(MultiBuffer::DEFAULT_TITLE.into()) .into(); let replica_id = new_buffer.replica_id(); diff --git a/crates/agent_ui/src/diagnostics.rs b/crates/agent_ui/src/diagnostics.rs index 5a2423cae65..75c21d68b75 100644 --- a/crates/agent_ui/src/diagnostics.rs +++ b/crates/agent_ui/src/diagnostics.rs @@ -1,6 +1,7 @@ use anyhow::Result; use gpui::{App, AppContext as _, Entity, Task}; use language::{Anchor, BufferSnapshot, DiagnosticEntryRef, DiagnosticSeverity, ToOffset}; +use multi_buffer::MultiBuffer; use project::{DiagnosticSummary, Project}; use rope::Point; use std::{fmt::Write, ops::RangeInclusive, path::Path}; @@ -22,7 +23,7 @@ pub fn codeblock_fence_for_path( write!(text, "{path}").unwrap(); } else { - write!(text, "untitled").unwrap(); + write!(text, "{}", MultiBuffer::DEFAULT_TITLE).unwrap(); } if let Some(row_range) = row_range { diff --git a/crates/edit_prediction_ui/src/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs index 0dc4c1aaf29..e9d36860852 100644 --- a/crates/edit_prediction_ui/src/rate_prediction_modal.rs +++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs @@ -1253,11 +1253,10 @@ impl RatePredictionsModal { }; let file = completion.buffer.read(cx).file(); - let file_name = file - .as_ref() - .map_or(SharedString::new_static("untitled"), |file| { - file.file_name(cx).to_string().into() - }); + let file_name = file.as_ref().map_or( + SharedString::new_static(MultiBuffer::DEFAULT_TITLE), + |file| file.file_name(cx).to_string().into(), + ); let file_path = file.map(|file| file.path().as_unix_str().to_string()); ListItem::new(completion.id.clone()) diff --git a/crates/editor/src/edit_prediction.rs b/crates/editor/src/edit_prediction.rs index 43e9b2ff375..5d110eb3ce1 100644 --- a/crates/editor/src/edit_prediction.rs +++ b/crates/editor/src/edit_prediction.rs @@ -2295,7 +2295,7 @@ impl Editor { let file_name = snapshot .file() .map(|file| SharedString::new(file.file_name(cx))) - .unwrap_or(SharedString::new_static("untitled")); + .unwrap_or(SharedString::new_static(MultiBuffer::DEFAULT_TITLE)); h_flex() .id("ep-jump-outside-popover") @@ -2429,7 +2429,7 @@ impl Editor { let file_name = snapshot .file() .map(|file| file.file_name(cx)) - .unwrap_or("untitled"); + .unwrap_or(MultiBuffer::DEFAULT_TITLE); Some( h_flex() .px_2() diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 73a0c3ebfdd..96567329721 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -10896,7 +10896,7 @@ impl Editor { if multibuffer.is_singleton() { multibuffer.title(cx).to_string() } else { - "untitled".to_string() + MultiBuffer::DEFAULT_TITLE.to_string() } }) }); diff --git a/crates/editor/src/element/header.rs b/crates/editor/src/element/header.rs index a6611b0c90a..0ba76a13761 100644 --- a/crates/editor/src/element/header.rs +++ b/crates/editor/src/element/header.rs @@ -12,7 +12,7 @@ use gpui::{ linear_color_stop, linear_gradient, point, px, size, }; use language::language_settings::ShowWhitespaceSetting; -use multi_buffer::{Anchor, ExcerptBoundaryInfo}; +use multi_buffer::{Anchor, ExcerptBoundaryInfo, MultiBuffer}; use project::Entry; use settings::{RelativeLineNumbers, Settings}; use smallvec::SmallVec; @@ -821,7 +821,7 @@ pub(crate) fn render_buffer_header( |path_header| { let filename = filename .map(SharedString::from) - .unwrap_or_else(|| "untitled".into()); + .unwrap_or_else(|| MultiBuffer::DEFAULT_TITLE.into()); let full_path = match parent_path.as_deref() { Some(parent) if !parent.is_empty() => { diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index f61546e289a..4e97c8c3fe3 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -18,8 +18,8 @@ use gpui::{ IntoElement, ParentElement, Pixels, SharedString, Styled, Task, WeakEntity, Window, point, }; use language::{ - Bias, Buffer, BufferRow, CharKind, CharScopeContext, HighlightedText, LocalFile, Point, - SelectionGoal, proto::serialize_anchor as serialize_text_anchor, + Bias, Buffer, BufferRow, CharKind, CharScopeContext, HighlightedText, LocalFile, PLAIN_TEXT, + Point, SelectionGoal, proto::serialize_anchor as serialize_text_anchor, }; use lsp::DiagnosticSeverity; use multi_buffer::{BufferOffset, MultiBufferOffset, PathKey}; @@ -717,7 +717,22 @@ impl Item for Editor { } fn suggested_filename(&self, cx: &App) -> SharedString { - self.buffer.read(cx).title(cx).to_string().into() + let multi_buffer = self.buffer.read(cx); + let title = multi_buffer.title(cx); + if let Some(buffer) = multi_buffer.as_singleton() { + let buffer = buffer.read(cx); + if buffer.file().is_none() + && let Some(language) = buffer.language() + && *language != *PLAIN_TEXT + && let Some(suffix) = language.path_suffixes().first() + && !suffix.is_empty() + && !title.ends_with(&format!(".{suffix}")) + { + return format!("{title}.{suffix}").into(); + } + } + + title.to_string().into() } fn tab_icon(&self, _: &Window, cx: &App) -> Option { @@ -2386,6 +2401,95 @@ mod tests { } } + #[gpui::test] + async fn test_suggested_filename_uses_language_extension_for_untitled_buffer( + cx: &mut gpui::TestAppContext, + ) { + init_test(cx, |_| {}); + + let buffer = cx.update(|cx| { + cx.new(|cx| Buffer::local("", cx).with_language(languages::rust_lang(), cx)) + }); + let (editor, cx) = + cx.add_window_view(|window, cx| Editor::for_buffer(buffer, None, window, cx)); + + editor.read_with(cx, |editor, cx| { + assert_eq!(editor.suggested_filename(cx).as_ref(), "untitled.rs"); + }); + } + + #[gpui::test] + async fn test_suggested_filename_appends_extension_to_content_title( + cx: &mut gpui::TestAppContext, + ) { + init_test(cx, |_| {}); + + let buffer = cx.update(|cx| { + cx.new(|cx| { + Buffer::local("sadsdsads\nmore text", cx).with_language(languages::rust_lang(), cx) + }) + }); + let (editor, cx) = + cx.add_window_view(|window, cx| Editor::for_buffer(buffer, None, window, cx)); + + editor.read_with(cx, |editor, cx| { + assert_eq!(editor.tab_content_text(0, cx).as_ref(), "sadsdsads"); + assert_eq!(editor.suggested_filename(cx).as_ref(), "sadsdsads.rs"); + }); + } + + #[gpui::test] + async fn test_suggested_filename_does_not_duplicate_extension(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let buffer = cx.update(|cx| { + cx.new(|cx| { + Buffer::local("main.rs\nfn main() {}", cx).with_language(languages::rust_lang(), cx) + }) + }); + let (editor, cx) = + cx.add_window_view(|window, cx| Editor::for_buffer(buffer, None, window, cx)); + + editor.read_with(cx, |editor, cx| { + assert_eq!(editor.suggested_filename(cx).as_ref(), "main.rs"); + }); + } + + #[gpui::test] + async fn test_suggested_filename_keeps_content_title_for_plain_text( + cx: &mut gpui::TestAppContext, + ) { + init_test(cx, |_| {}); + + let buffer = cx.update(|cx| { + cx.new(|cx| { + Buffer::local("shopping list\nmilk", cx) + .with_language(language::PLAIN_TEXT.clone(), cx) + }) + }); + let (editor, cx) = + cx.add_window_view(|window, cx| Editor::for_buffer(buffer, None, window, cx)); + + editor.read_with(cx, |editor, cx| { + assert_eq!(editor.suggested_filename(cx).as_ref(), "shopping list"); + }); + } + + #[gpui::test] + async fn test_suggested_filename_keeps_content_title_without_language( + cx: &mut gpui::TestAppContext, + ) { + init_test(cx, |_| {}); + + let buffer = cx.update(|cx| cx.new(|cx| Buffer::local("shopping list\nmilk", cx))); + let (editor, cx) = + cx.add_window_view(|window, cx| Editor::for_buffer(buffer, None, window, cx)); + + editor.read_with(cx, |editor, cx| { + assert_eq!(editor.suggested_filename(cx).as_ref(), "shopping list"); + }); + } + async fn deserialize_editor( item_id: ItemId, workspace_id: WorkspaceId, diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs index b046380b28a..a8159566da4 100644 --- a/crates/git_ui/src/file_diff_view.rs +++ b/crates/git_ui/src/file_diff_view.rs @@ -236,7 +236,7 @@ impl Item for FileDiffView { .to_string(), ) }) - .unwrap_or_else(|| "untitled".into()) + .unwrap_or_else(|| MultiBuffer::DEFAULT_TITLE.into()) }; let old_filename = title_text(&self.old_buffer); let new_filename = title_text(&self.new_buffer); @@ -250,7 +250,7 @@ impl Item for FileDiffView { .read(cx) .file() .map(|file| file.full_path(cx).compact().to_string_lossy().into_owned()) - .unwrap_or_else(|| "untitled".into()) + .unwrap_or_else(|| MultiBuffer::DEFAULT_TITLE.into()) }; let old_path = path(&self.old_buffer); let new_path = path(&self.new_buffer); diff --git a/crates/git_ui/src/multi_diff_view.rs b/crates/git_ui/src/multi_diff_view.rs index 7bf7682938c..47bf5a2b727 100644 --- a/crates/git_ui/src/multi_diff_view.rs +++ b/crates/git_ui/src/multi_diff_view.rs @@ -94,7 +94,7 @@ fn register_entry( RelPath::new(rel, PathStyle::local()) .map(|r| r.into_owned().into()) .unwrap_or_else(|_| { - RelPath::new(Path::new("untitled"), PathStyle::Posix) + RelPath::new(Path::new(MultiBuffer::DEFAULT_TITLE), PathStyle::Posix) .unwrap() .into_owned() .into() @@ -108,7 +108,7 @@ fn register_entry( .and_then(|s| RelPath::new(Path::new(s), PathStyle::Posix).ok()) .map(|r| r.into_owned().into()) .unwrap_or_else(|| { - RelPath::new(Path::new("untitled"), PathStyle::Posix) + RelPath::new(Path::new(MultiBuffer::DEFAULT_TITLE), PathStyle::Posix) .unwrap() .into_owned() .into() diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs index 991ebf21252..c9cf6b5f537 100644 --- a/crates/git_ui/src/text_diff_view.rs +++ b/crates/git_ui/src/text_diff_view.rs @@ -218,7 +218,7 @@ impl TextDiffView { .file() .map(|f| f.full_path(cx).compact().to_string_lossy().into_owned()) }) - .unwrap_or("untitled".into()); + .unwrap_or(MultiBuffer::DEFAULT_TITLE.into()); let selection_location_path = selection_location_text .map(|text| format!("{} @ {}", path, text)) diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 805ffc73550..e233f393b29 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -2114,6 +2114,9 @@ impl MultiBuffer { self.title.as_deref() } + /// The title used for buffers not backed by a file and with no title of their own. + pub const DEFAULT_TITLE: &str = "untitled"; + pub fn title<'a>(&'a self, cx: &'a App) -> Cow<'a, str> { if let Some(title) = self.title.as_ref() { return title.into(); @@ -2131,7 +2134,7 @@ impl MultiBuffer { } }; - "untitled".into() + Self::DEFAULT_TITLE.into() } fn buffer_content_title(&self, buffer: &Buffer) -> Option> { From ded93ccb085d9271c7820abc8c39b9a0a04b5d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Raz=20Guzm=C3=A1n=20Macedo?= Date: Wed, 8 Jul 2026 06:32:00 -0600 Subject: [PATCH 138/197] Fix typos and grammatical mistakes in docs (#59495) # Objective - Describe the objective or issue this PR addresses. -> ** Typo and grammar fixes!** ## Solution ## Testing ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase --- Release Notes: - N/A --- docs/src/ai/edit-prediction.md | 2 +- docs/src/ai/mcp.md | 2 +- docs/src/ai/parallel-agents.md | 2 +- docs/src/authentication.md | 2 +- docs/src/command-palette.md | 2 +- docs/src/configuring-languages.md | 2 +- docs/src/debugger.md | 2 +- docs/src/development/glossary.md | 2 +- docs/src/diagnostics.md | 4 ++-- docs/src/environment.md | 2 +- docs/src/extensions/debugger-extensions.md | 2 +- docs/src/extensions/developing-extensions.md | 12 ++++++------ docs/src/git.md | 4 ++-- docs/src/languages/ansible.md | 2 +- docs/src/languages/cpp.md | 2 +- docs/src/languages/elixir.md | 2 +- docs/src/languages/java.md | 2 +- docs/src/languages/javascript.md | 2 +- docs/src/languages/json.md | 2 +- docs/src/languages/powershell.md | 4 ++-- docs/src/languages/python.md | 2 +- docs/src/languages/rust.md | 4 ++-- docs/src/languages/typescript.md | 4 ++-- docs/src/linux.md | 2 +- docs/src/performance.md | 8 ++++---- docs/src/project-panel.md | 2 +- docs/src/reference/all-settings.md | 8 ++++---- docs/src/remote-development.md | 4 ++-- docs/src/repl.md | 4 ++-- docs/src/vim.md | 10 +++++----- docs/src/worktree-trust.md | 2 +- 31 files changed, 53 insertions(+), 53 deletions(-) diff --git a/docs/src/ai/edit-prediction.md b/docs/src/ai/edit-prediction.md index 297e3c32c71..00e94b0b07f 100644 --- a/docs/src/ai/edit-prediction.md +++ b/docs/src/ai/edit-prediction.md @@ -41,7 +41,7 @@ The free plan includes 2,000 Zeta predictions per month. The [Pro plan](../accou Edit Prediction has two display modes: -1. `eager` (default): predictions are displayed inline as long as it doesn't conflict with language server completions +1. `eager` (default): predictions are displayed inline as long as they don't conflict with language server completions 2. `subtle`: predictions only appear inline when holding a modifier key (`alt` by default) Toggle between them via the `mode` key: diff --git a/docs/src/ai/mcp.md b/docs/src/ai/mcp.md index 777fd0e9b08..89d7beac865 100644 --- a/docs/src/ai/mcp.md +++ b/docs/src/ai/mcp.md @@ -80,7 +80,7 @@ You can connect both local and remote MCP servers from **Settings → AI → MCP Most MCP servers require configuration after installation. -In the case of extensions, after installing it, Zed will pop up a modal displaying what is required for you to properly set it up. +In the case of an extension, after installing it, Zed will pop up a modal displaying what is required for you to properly set it up. For example, the GitHub MCP extension requires you to add a [Personal Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens). In the case of custom servers, make sure you check the provider documentation to determine what type of command, arguments, and environment variables need to be added to the JSON. diff --git a/docs/src/ai/parallel-agents.md b/docs/src/ai/parallel-agents.md index aef97e37f5a..70f0ff4f938 100644 --- a/docs/src/ai/parallel-agents.md +++ b/docs/src/ai/parallel-agents.md @@ -81,7 +81,7 @@ Worktrees are managed from the title bar. Click the worktree picker (to the righ Once you're in a new worktree, use the branch picker next to the worktree picker to create a new branch or check out an existing one. If the branch you pick is already checked out in another worktree, the current worktree stays in detached HEAD until you choose a different branch. -To automate setup steps whenever a new worktree is created use a [Task hook](../tasks.md#hooks). The `create_worktree` hook runs automatically after Zed creates a linked worktree, with `ZED_WORKTREE_ROOT` pointing at the new worktree and `ZED_MAIN_GIT_WORKTREE` pointing at the original repository. +To automate setup steps whenever a new worktree is created, use a [Task hook](../tasks.md#hooks). The `create_worktree` hook runs automatically after Zed creates a linked worktree, with `ZED_WORKTREE_ROOT` pointing at the new worktree and `ZED_MAIN_GIT_WORKTREE` pointing at the original repository. After the agent finishes, review the diff and merge the changes through your normal Git workflow. If the thread was running in a linked worktree and no other active threads use it, moving the thread to Thread History saves the worktree's Git state and removes it from disk. Restoring the thread from history restores the worktree. diff --git a/docs/src/authentication.md b/docs/src/authentication.md index 8d0b0b7c0dc..2565afc2ddf 100644 --- a/docs/src/authentication.md +++ b/docs/src/authentication.md @@ -38,5 +38,5 @@ Stripe is used for billing, and will use your Zed account's email address when s ## Hiding Sign In button from the interface -In case the Sign In feature is not used, it's possible to hide that from the interface by using `show_sign_in` settings property. +In case the Sign In feature is not used, it's possible to hide that from the interface by using the `show_sign_in` settings property. Refer to [Visual Customization page](./visual-customization.md) for more details. diff --git a/docs/src/command-palette.md b/docs/src/command-palette.md index cff57ca2c06..789e463d9c3 100644 --- a/docs/src/command-palette.md +++ b/docs/src/command-palette.md @@ -11,4 +11,4 @@ The Command Palette is the main way to access actions in Zed. Its keybinding is 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. +Any time you see instructions that include commands of the form `zed: ...` or `editor: ...` and so on, it 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 5113453a7ba..835fcd789d4 100644 --- a/docs/src/configuring-languages.md +++ b/docs/src/configuring-languages.md @@ -214,7 +214,7 @@ Here's how you would structure these settings in Zed's `settings.json`: #### Possible configuration options -Depending on how a particular language server is implemented, they may depend on different configuration options, both specified in the LSP. +Language servers may use different configuration options depending on the implementation. - [initializationOptions](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#version_3_17_0) diff --git a/docs/src/debugger.md b/docs/src/debugger.md index b503ff09849..4c7c21e5e38 100644 --- a/docs/src/debugger.md +++ b/docs/src/debugger.md @@ -74,7 +74,7 @@ Populate this file with the same array of objects you would place in `.zed/debug ### Launching & Attaching -Zed debugger offers two ways to debug your program; you can either _launch_ a new instance of your program or _attach_ to an existing process. +The Zed debugger offers two ways to debug your program; you can either _launch_ a new instance of your program or _attach_ to an existing process. Which one you choose depends on what you are trying to achieve. When launching a new instance, Zed (and the underlying debug adapter) can often do a better job at picking up the debug information compared to attaching to an existing process, since it controls the lifetime of a whole program. diff --git a/docs/src/development/glossary.md b/docs/src/development/glossary.md index 4e14aceba40..4b57a30d5ba 100644 --- a/docs/src/development/glossary.md +++ b/docs/src/development/glossary.md @@ -113,7 +113,7 @@ h_flex() - `DapStore`: Is an entity that manages debugger sessions - `debugger::Session`: An entity that manages the lifecycle of a debug session and communication with DAPs. -- `BreakpointStore`: Is an entity that manages breakpoints states in local and remote instances of Zed +- `BreakpointStore`: Is an entity that manages breakpoint states in local and remote instances of Zed - `DebugSession`: Manages a debug session's UI and running state - `RunningState`: Directly manages all the views of a debug session - `VariableList`: The variable and watch list view of a debug session diff --git a/docs/src/diagnostics.md b/docs/src/diagnostics.md index 5c019ecac36..7af28e062c5 100644 --- a/docs/src/diagnostics.md +++ b/docs/src/diagnostics.md @@ -29,12 +29,12 @@ The scrollbar ones are configured with the configuration (possible values: `"none"`, `"error"`, `"warning"`, `"information"`, `"all"` (default)) -The diagnostics could be hovered to display a tooltip with full, rendered diagnostic message. +The diagnostics could be hovered to display a tooltip with a full, rendered diagnostic message. Or, `editor::GoToDiagnostic` and `editor::GoToPreviousDiagnostic` could be used to navigate between diagnostics in the editor, showing a popover for the currently active diagnostic. # Inline diagnostics (Error lens) -Zed supports showing diagnostic as lens to the right of the code. +Zed supports showing diagnostics as a lens to the right of the code. This is disabled by default, but can either be temporarily turned on (or off) using the editor menu, or permanently, using the ```json [settings] diff --git a/docs/src/environment.md b/docs/src/environment.md index bc39c00fdd0..538134694ac 100644 --- a/docs/src/environment.md +++ b/docs/src/environment.md @@ -89,7 +89,7 @@ For this look-up, Zed uses the following environment: ### Language servers -After looking up a language server, Zed starts them. +After looking up a language server, Zed starts it. These language server processes always inherit Zed's process environment. But, depending on the language server look-up, additional environment variables might be set or overwrite the process environment. diff --git a/docs/src/extensions/debugger-extensions.md b/docs/src/extensions/debugger-extensions.md index 57b98234406..08d95119077 100644 --- a/docs/src/extensions/debugger-extensions.md +++ b/docs/src/extensions/debugger-extensions.md @@ -81,7 +81,7 @@ Your extension can define one or more debug locators. Each debug locator must be ``` Locators have two components. -First, each locator is ran on each available task to figure out if any of the available locators can provide a debug scenario for a given task. This is done by calling `dap_locator_create_scenario`. +First, each locator is run on each available task to figure out if any of the available locators can provide a debug scenario for a given task. This is done by calling `dap_locator_create_scenario`. ```rust impl zed::Extension for MyExtension { diff --git a/docs/src/extensions/developing-extensions.md b/docs/src/extensions/developing-extensions.md index d341684f89c..13dfa2a4aaf 100644 --- a/docs/src/extensions/developing-extensions.md +++ b/docs/src/extensions/developing-extensions.md @@ -68,7 +68,7 @@ my-extension/ ## Rust and WebAssembly -> Please note that most extensions will work properly without any Rust code present. In particular, only language server, context server and debugger extensions require the presence custom Rust in order to function properly. +> Please note that most extensions will work properly without any Rust code present. In particular, only language server, context server and debugger extensions require the presence of custom Rust in order to function properly. Procedural parts of extensions are written in Rust and compiled to WebAssembly. To develop an extension that includes custom code, include a `Cargo.toml` like this: @@ -103,7 +103,7 @@ impl zed::Extension for MyExtension { zed::register_extension!(MyExtension); ``` -> Since your extension will be compiled to WebAssembly, some Rust features might not work like you would expect them to. For example, `cfg` - directives will not work and `std::env::var` will also not yield the expected results. Instead, use the [`zed_extension_api::current_platform`](https://docs.rs/zed_extension_api/latest/zed_extension_api/fn.current_platform.html) method to get information about the current environment and familiarize yourself with the [`Worktree` struct and its methods](https://docs.rs/zed_extension_api/latest/zed_extension_api/struct.Worktree.html) for reading environment variables and finding binaries in the users `PATH`. +> Since your extension will be compiled to WebAssembly, some Rust features might not work like you would expect them to. For example, `cfg` - directives will not work and `std::env::var` will also not yield the expected results. Instead, use the [`zed_extension_api::current_platform`](https://docs.rs/zed_extension_api/latest/zed_extension_api/fn.current_platform.html) method to get information about the current environment and familiarize yourself with the [`Worktree` struct and its methods](https://docs.rs/zed_extension_api/latest/zed_extension_api/struct.Worktree.html) for reading environment variables and finding binaries in the user's `PATH`. ### Debugging your Rust extension @@ -158,15 +158,15 @@ Also, ensure that you have filled out all the required fields in the manifest. Furthermore, please make sure that your extension fulfills the following preconditions before you move on to publishing your extension: - Extension IDs and names must not contain the words `zed`, `Zed` or `extension`, since they are all Zed extensions. -- Your extension ID should provide some information on what your extension tries to accomplish. E.g. for themes, it should be suffixed with `-theme`, snippet extensions should be suffixed with `-snippets` and so on. An exception to that rule are extension that provide support for languages or popular tooling that people would expect to find under that ID. You can take a look at the list of [existing extensions](https://github.com/zed-industries/extensions/blob/main/extensions.toml) to get a grasp on how this usually is enforced. +- Your extension ID should provide some information on what your extension tries to accomplish. E.g. for themes, it should be suffixed with `-theme`, snippet extensions should be suffixed with `-snippets` and so on. An exception to that rule is an extension that provides support for languages or popular tooling that people would expect to find under that ID. You can take a look at the list of [existing extensions](https://github.com/zed-industries/extensions/blob/main/extensions.toml) to get a grasp on how this usually is enforced. - Your extension must only include the resources it requires to function and nothing else. - See the [directory structure of a Zed extension](#directory-structure-of-a-zed-extension) and the [Rust and WebAssembly](#rust-and-webassembly) sections for more information. - Extensions must in no way attempt to read nor modify the environment outside of the environment designated to them by Zed. Should they need to read the environment, they should use methods as provided by the [Zed Rust Extension API](https://docs.rs/zed_extension_api/latest/zed_extension_api/) and may fall back to appropriate methods from the Rust standard library. Should they need changes to the environment, they must instead ask the user to perform these for them using an appropriate method within the context (e.g. provide information for doing so using the `ContextServerConfiguration` for context servers). - Please make sure to have read the [Rust and WebAssembly section above](#rust-and-webassembly) for more information and help regarding this topic. - Extensions should provide something that is not yet available in the marketplace as opposed to fixing something that could be resolved within an existing extension. For example, if you find that an existing extension's support for a language server is not functioning properly, first try contributing a fix to the existing extension as opposed to submitting a new extension immediately. - If you receive no response or reaction within the upstream repository within a reasonable amount of time, feel free to submit a pull request that aims to fix said issue. Please ensure that you provide your previous efforts within the pull request to the extensions repository for adding your extension. Zed maintainers will then decide on how to proceed on a case by case basis. -- Extensions that intend to provide a language, debugger or MCP server must not ship the language server as part of the extension. Instead, the extension should either download the language server or check for the availability of the language server in the users environment using the APIs as provided by the [Zed Rust Extension API](https://docs.rs/zed_extension_api/latest/zed_extension_api/). -- Themes and icon themes should not be published as part of extensions that provide other features, e.g. language support. Instead, they should be published as a distinct extension. This also applies to theme and icon themes living in the same repository. +- Extensions that intend to provide a language, debugger or MCP server must not ship the language server as part of the extension. Instead, the extension should either download the language server or check for the availability of the language server in the user's environment using the APIs as provided by the [Zed Rust Extension API](https://docs.rs/zed_extension_api/latest/zed_extension_api/). +- Themes and icon themes should not be published as part of extensions that provide other features, e.g. language support. Instead, they should be published as a distinct extension. This also applies to themes and icon themes living in the same repository. Non-compliance with these rules will be raised during the publishing process by reviewers. If you fail to comply with the laid out guidelines, the publishing of your extension will either be delayed or rejected. @@ -185,7 +185,7 @@ git submodule add https://github.com/your-username/foobar-zed.git extensions/my- git add extensions/my-extension ``` -> All extension submodules must use HTTPS URLs and not SSH URLS (`git@github.com`). Furthermore, your extension repository must be publicly available and the checked out submodule commit must be on a branch and thus not be detached commit. +> All extension submodules must use HTTPS URLs and not SSH URLS (`git@github.com`). Furthermore, your extension repository must be publicly available and the checked out submodule commit must be on a branch and thus not be a detached commit. 2. Add a new entry to the top-level `extensions.toml` file containing your extension: diff --git a/docs/src/git.md b/docs/src/git.md index 68514d44092..07f2a00556e 100644 --- a/docs/src/git.md +++ b/docs/src/git.md @@ -293,7 +293,7 @@ See [Feature-specific models](./ai/agent-settings.md#feature-specific-models) fo } ``` -To add custom commit instructions for the model, use the global `AGENTS.md` file located `~/.config/zed/AGENTS.md` on macOS and Linux, `%APPDATA%\Zed\AGENTS.md` on Windows. +To add custom commit instructions for the model, use the global `AGENTS.md` file located at `~/.config/zed/AGENTS.md` on macOS and Linux, `%APPDATA%\Zed\AGENTS.md` on Windows. To add custom instructions that apply only to commit message generation, use the `commit_message_instructions` field in your agent settings: @@ -305,7 +305,7 @@ To add custom instructions that apply only to commit message generation, use the } ``` -These instructions are sent to the model in addition to any instruction files, such as `.rules` or `AGENTS.md`. To add instructions that apply to both commit messages and the agent more broadly, use the global `AGENTS.md` file located `~/.config/zed/AGENTS.md` on macOS and Linux, `%APPDATA%\Zed\AGENTS.md` on Windows. +These instructions are sent to the model in addition to any instruction files, such as `.rules` or `AGENTS.md`. To add instructions that apply to both commit messages and the agent more broadly, use the global `AGENTS.md` file located at `~/.config/zed/AGENTS.md` on macOS and Linux, `%APPDATA%\Zed\AGENTS.md` on Windows. > Before Zed v1.4.0, this was done through the Rules Library, which has been removed. > See [Migrating from Rules](./ai/instructions.md#migrating-from-rules) for more information. diff --git a/docs/src/languages/ansible.md b/docs/src/languages/ansible.md index fd595bc7e33..cf85b68fbe2 100644 --- a/docs/src/languages/ansible.md +++ b/docs/src/languages/ansible.md @@ -57,7 +57,7 @@ If your inventory file is in the YAML format, you can either: # yaml-language-server: $schema=https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/inventory.json ``` -- or, configure the YAML language server settings to set this schema for all your inventory files, that match your inventory pattern, under your Zed settings ([ref](https://zed.dev/docs/languages/yaml)): +- or, configure the YAML language server settings to set this schema for all your inventory files that match your inventory pattern, under your Zed settings ([ref](https://zed.dev/docs/languages/yaml)): ```json [settings] { diff --git a/docs/src/languages/cpp.md b/docs/src/languages/cpp.md index 44025da5544..e8e8acf9f61 100644 --- a/docs/src/languages/cpp.md +++ b/docs/src/languages/cpp.md @@ -176,7 +176,7 @@ Automatically dims inactive sections of code due to preprocessor directives, suc ### Switch Between Source and Header Files -Allows switching between corresponding C++ source files (e.g., `.cpp`) and header files (e.g., `.h`). +Allows switching between corresponding C++ source files (e.g., `.cpp`) and header files (e.g., `.h`) by running the command {#action editor::SwitchSourceHeader} from the command palette or by setting a keybinding for the `editor::SwitchSourceHeader` action. diff --git a/docs/src/languages/elixir.md b/docs/src/languages/elixir.md index cc9f498a3d5..8a290a0d7a7 100644 --- a/docs/src/languages/elixir.md +++ b/docs/src/languages/elixir.md @@ -163,7 +163,7 @@ Enable Next LS by adding the following to your settings file: Next LS can accept initialization options. -Completions are an experimental feature within Next LS, they are enabled by default in Zed. Disable them by adding the following to your settings file: +Completions are an experimental feature within Next LS, and are enabled by default in Zed. Disable them by adding the following to your settings file: ```json [settings] "lsp": { diff --git a/docs/src/languages/java.md b/docs/src/languages/java.md index a789506e1c5..a9bc465e2e0 100644 --- a/docs/src/languages/java.md +++ b/docs/src/languages/java.md @@ -82,7 +82,7 @@ You should then be able to start a new Debug Session with the "Launch Debugger" This extension provides tasks for running your application and tests from within Zed via little play buttons next to tests/entry points. However, due to current limitations of Zed's extension interface, we can not provide scripts that will work across Maven and Gradle on both Windows and Unix-compatible systems, so out of the box the launch scripts only work on Mac and Linux. -There is a fairly straightforward fix that you can apply to make it work on Windows by supplying your own task scripts. Please see [this Issue](https://github.com/zed-extensions/java/issues/94) for information on how to do that and read the [Tasks section in Zeds documentation](https://zed.dev/docs/tasks) for more information. +There is a fairly straightforward fix that you can apply to make it work on Windows by supplying your own task scripts. Please see [this Issue](https://github.com/zed-extensions/java/issues/94) for information on how to do that and read the [Tasks section in Zed's documentation](https://zed.dev/docs/tasks) for more information. ## Advanced Configuration/JDTLS initialization Options diff --git a/docs/src/languages/javascript.md b/docs/src/languages/javascript.md index 47511ec713d..16e5c2ad604 100644 --- a/docs/src/languages/javascript.md +++ b/docs/src/languages/javascript.md @@ -107,7 +107,7 @@ You can also only execute a single ESLint rule when using `fixAll`: ``` > **Note:** the other formatter you have configured will still run, after ESLint. -> So if your language server or Prettier configuration don't format according to +> So if your language server or Prettier configuration doesn't format according to > ESLint's rules, then they will overwrite what ESLint fixed and you end up with > errors. diff --git a/docs/src/languages/json.md b/docs/src/languages/json.md index 41644a8b055..dcede992407 100644 --- a/docs/src/languages/json.md +++ b/docs/src/languages/json.md @@ -43,7 +43,7 @@ Zed automatically out of the box supports JSON Schema validation of `package.jso To specify a schema inline with your JSON files, add a `$schema` top level key linking to your json schema file. -For example to for a `.luarc.json` for use with [lua-language-server](https://github.com/LuaLS/lua-language-server/): +For example, for a `.luarc.json` for use with [lua-language-server](https://github.com/LuaLS/lua-language-server/): ```json { diff --git a/docs/src/languages/powershell.md b/docs/src/languages/powershell.md index 27e5daf09c5..79cf7d65795 100644 --- a/docs/src/languages/powershell.md +++ b/docs/src/languages/powershell.md @@ -25,9 +25,9 @@ The Zed PowerShell extension will default to the `pwsh` executable found in your ### Install PowerShell Editor Services (Optional) {#powershell-editor-services} -The Zed PowerShell extensions will attempt to download [PowerShell Editor Services](https://github.com/PowerShell/PowerShellEditorServices) automatically. +The Zed PowerShell extension will attempt to download [PowerShell Editor Services](https://github.com/PowerShell/PowerShellEditorServices) automatically. -If want to use a specific binary, you can specify in your that in your Zed settings.json: +If you want to use a specific binary, you can specify that in your Zed settings.json: ```json [settings] "lsp": { diff --git a/docs/src/languages/python.md b/docs/src/languages/python.md index 0dd931f5140..4ca22cd9f83 100644 --- a/docs/src/languages/python.md +++ b/docs/src/languages/python.md @@ -444,4 +444,4 @@ If a language server isn't responding or features like diagnostics or autocomple - Verify your `settings.json` or `pyrightconfig.json` is syntactically correct. - Restart Zed to reinitialize language server connections, or try restarting the language server using the {#action editor::RestartLanguageServer} -If the language server is failing to resolve imports, and you're using a virtual environment, make sure that the right environment is chosen in the selector. You can use "Server Info" view to confirm which virtual environment Zed is sending to the language server—look for the `* Configuration` section at the end. +If the language server is failing to resolve imports, and you're using a virtual environment, make sure that the right environment is chosen in the selector. You can use the "Server Info" view to confirm which virtual environment Zed is sending to the language server—look for the `* Configuration` section at the end. diff --git a/docs/src/languages/rust.md b/docs/src/languages/rust.md index 8568ca27ffd..bc6a97a6d96 100644 --- a/docs/src/languages/rust.md +++ b/docs/src/languages/rust.md @@ -70,7 +70,7 @@ You can configure which `rust-analyzer` binary Zed should use. By default, Zed will try to find a `rust-analyzer` in your `$PATH` and try to use that. If that binary successfully executes `rust-analyzer --help`, it's used. Otherwise, Zed will fall back to installing its own stable `rust-analyzer` version and use that. -If you want to install pre-release `rust-analyzer` version instead you can instruct Zed to do so by setting `pre_release` to `true` in your `settings.json`: +If you want to install a pre-release `rust-analyzer` version instead, you can instruct Zed to do so by setting `pre_release` to `true` in your `settings.json`: ```json [settings] { @@ -163,7 +163,7 @@ If disabled with `checkOnSave: false` (see the example of the server configurati TBD: Is it possible to specify RUSTFLAGS? https://github.com/zed-industries/zed/issues/14334 --> -Rust-analyzer [manual](https://rust-analyzer.github.io/book/) describes various features and configuration options for rust-analyzer language server. +The Rust-analyzer [manual](https://rust-analyzer.github.io/book/) describes various features and configuration options for the rust-analyzer language server. Rust-analyzer in Zed runs with the default parameters. ### Large projects and performance diff --git a/docs/src/languages/typescript.md b/docs/src/languages/typescript.md index c4c454118ec..d67c65c248b 100644 --- a/docs/src/languages/typescript.md +++ b/docs/src/languages/typescript.md @@ -254,13 +254,13 @@ If your use-case isn't covered by any of these, you can take full control by add ### Configuring JavaScript debug tasks -JavaScript debugging is more complicated than other languages because there are two different environments: Node.js and the browser. `vscode-js-debug` exposes a `type` field, that you can use to specify the environment, either `node` or `chrome`. +JavaScript debugging is more complicated than for other languages because there are two different environments: Node.js and the browser. `vscode-js-debug` exposes a `type` field, that you can use to specify the environment, either `node` or `chrome`. - [vscode-js-debug configuration documentation](https://github.com/microsoft/vscode-js-debug/blob/main/OPTIONS.md) ### Attach debugger to a server running in web browser (`npx serve`) -Given an externally-ran web server (e.g., with `npx serve` or `npx live-server`) one can attach to it and open it with a browser. +Given an externally-run web server (e.g., with `npx serve` or `npx live-server`) one can attach to it and open it with a browser. ```json [debug] [ diff --git a/docs/src/linux.md b/docs/src/linux.md index 410e8f153f0..bfc61e90455 100644 --- a/docs/src/linux.md +++ b/docs/src/linux.md @@ -134,7 +134,7 @@ If Zed was installed using a package manager, please consult the documentation f ## Troubleshooting -Linux works on a large variety of systems configured in many different ways. We primarily test Zed on a vanilla Ubuntu setup, as it is the most common distribution our users use, that said we do expect it to work on a wide variety of machines. +Linux works on a large variety of systems configured in many different ways. We primarily test Zed on a vanilla Ubuntu setup, as it is the most common distribution our users use. That said, we do expect it to work on a wide variety of machines. ### Zed fails to start diff --git a/docs/src/performance.md b/docs/src/performance.md index c97dd23d195..5ecb906289e 100644 --- a/docs/src/performance.md +++ b/docs/src/performance.md @@ -22,7 +22,7 @@ The profile.json does not contain any symbols. Firefox profiler can add the loca See how long each annotated function call took and its arguments (if configured). -Annotate any function you need appear in the profile with instrument. For more +Annotate any function you need to appear in the profile with instrument. For more details see [tracing-instrument](https://docs.rs/tracing/latest/tracing/attr.instrument.html): @@ -33,7 +33,7 @@ fn should_appear_in_profile(kitty: Cat) { } ``` -Then either compile Zed with `ZTRACING=1 cargo r --features tracy --release`. The release build is optional but highly recommended as like every program Zeds performance characteristics change dramatically with optimizations. You do not want to chase slowdowns that do not exist in release. +Then either compile Zed with `ZTRACING=1 cargo r --features tracy --release`. The release build is optional but highly recommended as like every program Zed's performance characteristics change dramatically with optimizations. You do not want to chase slowdowns that do not exist in release. ## One time Setup/Building the profiler: @@ -55,7 +55,7 @@ Open the profiler (tracy-profiler), you should see zed in the list of `Discovere image -Tracy is an incredibly powerful profiler which can do a lot however it's UI is not that friendly. This is not the place for an in depth guide to Tracy, I do however want to highlight one particular workflow that is helpful when figuring out why a piece of code is _sometimes_ slow. +Tracy is an incredibly powerful profiler which can do a lot; however, its UI is not that friendly. This is not the place for an in depth guide to Tracy, I do however want to highlight one particular workflow that is helpful when figuring out why a piece of code is _sometimes_ slow. Here are the steps: @@ -83,7 +83,7 @@ Here are the steps: Scroll to zoom in -7. Click on a caller to to get statistics on _it_. +7. Click on a caller to get statistics on _it_. Click on any of the zones to get statistics diff --git a/docs/src/project-panel.md b/docs/src/project-panel.md index c1a190d8905..afee88bbf36 100644 --- a/docs/src/project-panel.md +++ b/docs/src/project-panel.md @@ -24,7 +24,7 @@ permanent tab. Editing the file or double-clicking it promotes it to a permanent ### Auto-reveal -By default, switching files in the editor will automatically highlight it in the +By default, switching to a file in the editor will automatically highlight it in the project panel and scroll it into view. This can be disabled with the `project_panel.auto_reveal_entries` setting. diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index ebeacf2bb96..1ca24ef7616 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -2740,7 +2740,7 @@ Example: **Options** -Run the {#action icon_theme_selector::Toggle} action in the command palette to see a current list of valid icon themes names. +Run the {#action icon_theme_selector::Toggle} action in the command palette to see a current list of valid icon theme names. ### Light @@ -2750,7 +2750,7 @@ Run the {#action icon_theme_selector::Toggle} action in the command palette to s **Options** -Run the {#action icon_theme_selector::Toggle} action in the command palette to see a current list of valid icon themes names. +Run the {#action icon_theme_selector::Toggle} action in the command palette to see a current list of valid icon theme names. ## Image Viewer @@ -4855,7 +4855,7 @@ Example command to set the title: `echo -e "\e]2;New Title\007";` **Options** -Run the {#action theme_selector::Toggle} action in the command palette to see a current list of valid themes names. +Run the {#action theme_selector::Toggle} action in the command palette to see a current list of valid theme names. ### Light @@ -4865,7 +4865,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a **Options** -Run the {#action theme_selector::Toggle} action in the command palette to see a current list of valid themes names. +Run the {#action theme_selector::Toggle} action in the command palette to see a current list of valid theme names. ## Title Bar diff --git a/docs/src/remote-development.md b/docs/src/remote-development.md index 9c9e8152fe6..d23f9520aa7 100644 --- a/docs/src/remote-development.md +++ b/docs/src/remote-development.md @@ -18,7 +18,7 @@ Remote development requires two computers, your local machine that runs the Zed On your local machine, Zed runs its UI, talks to language models, uses Tree-sitter to parse and syntax-highlight code, and stores unsaved changes and recent projects. The source code, language servers, tasks, and the terminal all run on the remote server. [AI features](./ai/overview.md) work in remote sessions, including the Agent Panel and Inline Assistant. -> **Note:** The original version of remote development sent traffic via Zed's servers. As of Zed v0.157 you can no-longer use that mode. +> **Note:** The original version of remote development sent traffic via Zed's servers. As of Zed v0.157 you can no longer use that mode. ## Setup @@ -244,7 +244,7 @@ If you do this, you must upload it to `~/.zed_server/zed-remote-server-{RELEASE_ ## Maintaining the SSH connection -Once the server is initialized. Zed will create new SSH connections (reusing the existing ControlMaster) to run the remote development server. +Once the server is initialized, Zed will create new SSH connections (reusing the existing ControlMaster) to run the remote development server. Each connection tries to run the development server in proxy mode. This mode will start the daemon if it is not running, and reconnect to it if it is. This way when your connection drops and is restarted, you can continue to work without interruption. diff --git a/docs/src/repl.md b/docs/src/repl.md index b23bb27094f..255e1a40add 100644 --- a/docs/src/repl.md +++ b/docs/src/repl.md @@ -79,7 +79,7 @@ On macOS, your system Python will _not_ work. Either set up [pyenv](https://gith -To setup your current Python to have an available kernel, run: +To set up your current Python to have an available kernel, run: ```sh pip install ipykernel @@ -217,4 +217,4 @@ Available kernels: rust /Users/z/Library/Jupyter/kernels/rust ``` -> Note: Zed makes best effort usage of `sys.prefix` and `CONDA_PREFIX` to find kernels in Python environments. If you want to explicitly control run `python -m ipykernel install --user --name myenv --display-name "Python (myenv)"` to install the kernel directly while in the environment. +> Note: Zed makes best effort usage of `sys.prefix` and `CONDA_PREFIX` to find kernels in Python environments. If you want to explicitly control this, run `python -m ipykernel install --user --name myenv --display-name "Python (myenv)"` to install the kernel directly while in the environment. diff --git a/docs/src/vim.md b/docs/src/vim.md index b8447b83e56..db8bc03d40e 100644 --- a/docs/src/vim.md +++ b/docs/src/vim.md @@ -297,11 +297,11 @@ These ex commands open Zed's various panels and windows. These commands navigate diagnostics. -| Command | Description | -| ------------------------ | ------------------------------ | -| `:cn[ext]` or `:ln[ext]` | Go to the next diagnostic | -| `:cp[rev]` or `:lp[rev]` | Go to the previous diagnostics | -| `:cc` or `:ll` | Open the errors page | +| Command | Description | +| ------------------------ | ----------------------------- | +| `:cn[ext]` or `:ln[ext]` | Go to the next diagnostic | +| `:cp[rev]` or `:lp[rev]` | Go to the previous diagnostic | +| `:cc` or `:ll` | Open the errors page | ### Git diff --git a/docs/src/worktree-trust.md b/docs/src/worktree-trust.md index 4d5a18d7b20..4c190143c5e 100644 --- a/docs/src/worktree-trust.md +++ b/docs/src/worktree-trust.md @@ -15,7 +15,7 @@ To let users choose based on their own threat model and risk tolerance, all work Zed still trusts tools it installs globally. Global MCP servers and global language servers such as Prettier and Copilot are installed and started as usual, independent of worktree trust. -If a worktree is not trusted, Zed will indicate this with an exclamation mark icon in the title bar. Clicking this icon or using `workspace::ToggleWorktreeSecurity` action will bring up the security modal that allows the user to trust the worktree. +If a worktree is not trusted, Zed will indicate this with an exclamation mark icon in the title bar. Clicking this icon or using the `workspace::ToggleWorktreeSecurity` action will bring up the security modal that allows the user to trust the worktree. Trusting a worktree persists that decision between restarts. You can clear all trusted worktrees with the `workspace::ClearTrustedWorktrees` command. This command will restart Zed, to ensure no untrusted settings, language servers or MCP servers persist. From 739f23926d58a5c4dbda736e5b3b2ebd943337de Mon Sep 17 00:00:00 2001 From: Michael Egger Date: Wed, 8 Jul 2026 15:36:29 +0300 Subject: [PATCH 139/197] grammars: Recognize Gentoo `ebuild` files as bash script (#59068) The Gentoo `ebuild` file format is a subset of a bash script, see https://wiki.gentoo.org/wiki/Ebuild. 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - Changed `ebuild` files to be recognized as bash. Signed-off-by: gcarq Co-authored-by: Kirill Bulatov --- crates/edit_prediction_cli/src/filter_languages.rs | 5 +++++ crates/grammars/src/bash/config.toml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/edit_prediction_cli/src/filter_languages.rs b/crates/edit_prediction_cli/src/filter_languages.rs index cdf503fa23c..754ca7d1ba9 100644 --- a/crates/edit_prediction_cli/src/filter_languages.rs +++ b/crates/edit_prediction_cli/src/filter_languages.rs @@ -514,6 +514,11 @@ mod tests { detect_language(".env", &map), Some("Shell Script".to_string()) ); + // Gentoo ebuild files are a subset of bash + assert_eq!( + detect_language("app-editors/zed-1.5.4.ebuild", &map), + Some("Shell Script".to_string()) + ); } #[test] diff --git a/crates/grammars/src/bash/config.toml b/crates/grammars/src/bash/config.toml index adc2063341a..d7cabce3c2a 100644 --- a/crates/grammars/src/bash/config.toml +++ b/crates/grammars/src/bash/config.toml @@ -1,7 +1,7 @@ name = "Shell Script" code_fence_block_name = "bash" grammar = "bash" -path_suffixes = ["sh", "bash", "bashrc", "bash_profile", "bash_aliases", "bash_logout", "bats", "envrc", "profile", "zsh", "zshrc", "zshenv", "zsh_profile", "zsh_aliases", "zsh_histfile", "zlogin", "zprofile", ".env", "PKGBUILD", "APKBUILD"] +path_suffixes = ["sh", "bash", "bashrc", "bash_profile", "bash_aliases", "bash_logout", "bats", "envrc", "profile", "zsh", "zshrc", "zshenv", "zsh_profile", "zsh_aliases", "zsh_histfile", "zlogin", "zprofile", ".env", "PKGBUILD", "APKBUILD", "ebuild"] modeline_aliases = ["sh", "shell", "shell-script", "zsh"] line_comments = ["# "] first_line_pattern = '^#!.*\b(?:ash|bash|bats|dash|sh|zsh)\b' From f5c975162cf217f2c9cd1a2c1192eb2bb4653cdc Mon Sep 17 00:00:00 2001 From: Tianze Zhao Date: Wed, 8 Jul 2026 20:41:48 +0800 Subject: [PATCH 140/197] terminal: Open links with Cmd/Ctrl-click when mouse mode is enabled (#60067) This fixes #56956. The terminal link-open gesture was split between `mouse_down` and `mouse_up`, but both halves only ran in the non-mouse-reporting path. When a foreground app enabled terminal mouse reporting, a secondary click on a URL or file path was forwarded to the PTY instead of opening the link. This changes secondary-click link handling so it checks for a hyperlink before mouse reporting on press, and completes the matching hyperlink open before mouse reporting on release. The event is only consumed when the click actually lands on the same link, so normal clicks in mouse-reporting TUIs continue to be forwarded as before. Validation: - `CARGO_BUILD_JOBS=1 cargo test -j1 -p terminal test_hyperlink_ctrl_click_same_position_in_mouse_mode` - `CARGO_BUILD_JOBS=1 cargo check -j1 -p terminal` Release Notes: - Improved terminal links: Cmd/Ctrl-click now opens links even when the application has mouse reporting enabled (e.g. vim, opencode, claude). Disable `terminal.open_links_in_mouse_mode` to forward these clicks to the application instead. --------- Co-authored-by: Smit Barmase --- assets/settings/default.json | 6 + crates/settings/src/vscode_import.rs | 1 + crates/settings_content/src/terminal.rs | 8 + crates/settings_ui/src/page_data.rs | 25 +- crates/terminal/src/terminal.rs | 278 ++++++++++++++++++++--- crates/terminal/src/terminal_settings.rs | 2 + docs/src/reference/all-settings.md | 21 ++ docs/src/terminal.md | 10 + 8 files changed, 314 insertions(+), 37 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 8725587ade6..983784e8951 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1897,6 +1897,12 @@ "copy_on_select": false, // Whether to keep the text selection after copying it to the clipboard. "keep_selection_on_copy": true, + // Whether cmd-click (ctrl-click on Linux and Windows) opens hyperlinks even + // when the terminal application has enabled mouse reporting (e.g. vim with + // mouse=a, htop). When false, these clicks are forwarded to the application + // instead, and hyperlinks can still be opened with shift-cmd-click + // (shift-ctrl-click). + "open_links_in_mouse_mode": true, // Whether to show the terminal button in the status bar "button": true, // Any key-value pairs added to this list will be added to the terminal's diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index f7ad53118d3..9b8584c4e6b 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -899,6 +899,7 @@ impl VsCodeSettings { .map(FontSize::from), font_weight: None, keep_selection_on_copy: None, + open_links_in_mouse_mode: None, line_height: self .read_f32("terminal.integrated.lineHeight") .map(|lh| TerminalLineHeight::Custom(lh)), diff --git a/crates/settings_content/src/terminal.rs b/crates/settings_content/src/terminal.rs index c7c1013278f..e66795a193f 100644 --- a/crates/settings_content/src/terminal.rs +++ b/crates/settings_content/src/terminal.rs @@ -124,6 +124,14 @@ pub struct TerminalSettingsContent { /// /// Default: true pub keep_selection_on_copy: Option, + /// Whether cmd-click (ctrl-click on Linux and Windows) opens hyperlinks even + /// when the terminal application has enabled mouse reporting (e.g. vim with + /// mouse=a, htop). When false, these clicks are forwarded to the application + /// instead, and hyperlinks can still be opened with shift-cmd-click + /// (shift-ctrl-click). + /// + /// Default: true + pub open_links_in_mouse_mode: Option, /// Whether to show the terminal button in the status bar. /// /// Default: true diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 94dff0038f0..ba35c4875ef 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -7115,7 +7115,7 @@ fn terminal_page() -> SettingsPage { ] } - fn behavior_settings_section() -> [SettingsPageItem; 5] { + fn behavior_settings_section() -> [SettingsPageItem; 6] { [ SettingsPageItem::SectionHeader("Behavior Settings"), SettingsPageItem::SettingItem(SettingItem { @@ -7179,6 +7179,29 @@ fn terminal_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Open Links In Mouse Mode", + description: "Whether cmd-click (ctrl-click on Linux and Windows) opens hyperlinks even when the terminal application has enabled mouse reporting. When disabled, these clicks are forwarded to the application; links can still be opened with shift-cmd-click.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("terminal.open_links_in_mouse_mode"), + pick: |settings_content| { + settings_content + .terminal + .as_ref()? + .open_links_in_mouse_mode + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .terminal + .get_or_insert_default() + .open_links_in_mouse_mode = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Audible Bell", description: "Whether to play a sound when the BEL character (`\\a`, `0x07`) is printed", diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 9fbfd575ded..796593fe45d 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -1004,6 +1004,8 @@ impl TerminalBuilder { path_style, #[cfg(any(test, feature = "test-support"))] input_log: Vec::new(), + #[cfg(any(test, feature = "test-support"))] + pty_write_log: Default::default(), }; TerminalBuilder { @@ -1275,6 +1277,8 @@ impl TerminalBuilder { path_style, #[cfg(any(test, feature = "test-support"))] input_log: Vec::new(), + #[cfg(any(test, feature = "test-support"))] + pty_write_log: Default::default(), }; if !activation_script.is_empty() && no_task { @@ -1442,6 +1446,8 @@ pub struct Terminal { path_style: PathStyle, #[cfg(any(test, feature = "test-support"))] input_log: Vec>, + #[cfg(any(test, feature = "test-support"))] + pty_write_log: std::cell::RefCell>>, } struct CopyTemplate { @@ -1964,8 +1970,10 @@ impl Terminal { /// Write the Input payload to the PTY, if applicable. /// (This is a no-op for display-only terminals.) fn write_to_pty(&self, input: impl Into>) { + let input = input.into(); + #[cfg(any(test, feature = "test-support"))] + self.pty_write_log.borrow_mut().push(input.to_vec()); if let TerminalType::Pty { pty_tx, .. } = &self.terminal_type { - let input = input.into(); if log::log_enabled!(log::Level::Debug) { if let Ok(str) = str::from_utf8(&input) { log::debug!("Writing to PTY: {:?}", str); @@ -2097,6 +2105,11 @@ impl Terminal { std::mem::take(&mut self.input_log) } + #[cfg(any(test, feature = "test-support"))] + pub fn take_pty_write_log(&mut self) -> Vec> { + std::mem::take(self.pty_write_log.get_mut()) + } + #[cfg(any(test, feature = "test-support"))] pub fn keyboard_input_sent(&self) -> bool { self.keyboard_input_sent @@ -2307,22 +2320,30 @@ impl Terminal { pub fn mouse_move(&mut self, e: &MouseMoveEvent, cx: &mut Context) { let position = e.position - self.last_content.terminal_bounds.bounds.origin; if self.mouse_mode(e.modifiers.shift) { - let (point, side) = grid_point_and_side( - position, - self.last_content.terminal_bounds, - self.last_content.display_offset, - ); - - if self.mouse_changed(point, side) { - let bytes = mouse_moved_report( - point, - e.pressed_button, - e.modifiers, - self.last_content.mode, + // A ctrl/cmd press on a link suppressed its button-press report in + // `mouse_down`. Since the app never saw the press, we must swallow + // the whole gesture rather than forward later motion/release + // reports, which would be a press-less (malformed) sequence. + // `mouse_up` resolves it: release on the same link opens it, + // otherwise the gesture is dropped. + if self.mouse_down_hyperlink.is_none() { + let (point, side) = grid_point_and_side( + position, + self.last_content.terminal_bounds, + self.last_content.display_offset, ); - if let Some(bytes) = bytes { - self.write_to_pty(bytes); + if self.mouse_changed(point, side) { + let bytes = mouse_moved_report( + point, + e.pressed_button, + e.modifiers, + self.last_content.mode, + ); + + if let Some(bytes) = bytes { + self.write_to_pty(bytes); + } } } } else { @@ -2444,7 +2465,7 @@ impl Terminal { Some(scroll_lines.clamp(-3, 3)) } - pub fn mouse_down(&mut self, e: &MouseDownEvent, _cx: &mut Context) { + pub fn mouse_down(&mut self, e: &MouseDownEvent, cx: &mut Context) { let position = e.position - self.last_content.terminal_bounds.bounds.origin; let point = grid_point( position, @@ -2454,7 +2475,8 @@ impl Terminal { if e.button == MouseButton::Left && e.modifiers.secondary() - && !self.mouse_mode(e.modifiers.shift) + && (TerminalSettings::get_global(cx).open_links_in_mouse_mode + || !self.mouse_mode(e.modifiers.shift)) { self.mouse_down_hyperlink = self.find_hyperlink_at_point(point); @@ -2504,7 +2526,7 @@ impl Terminal { } #[cfg(any(target_os = "linux", target_os = "freebsd"))] MouseButton::Middle => { - if let Some(item) = _cx.read_from_primary() { + if let Some(item) = cx.read_from_primary() { let text = item.text().unwrap_or_default(); self.paste(&text); } @@ -2518,6 +2540,33 @@ impl Terminal { let setting = TerminalSettings::get_global(cx); let position = e.position - self.last_content.terminal_bounds.bounds.origin; + if let Some(mouse_down_hyperlink) = self.mouse_down_hyperlink.take() { + let point = grid_point( + position, + self.last_content.terminal_bounds, + self.last_content.display_offset, + ); + + if self + .find_hyperlink_at_point(point) + .is_some_and(|mouse_up_hyperlink| mouse_up_hyperlink == mouse_down_hyperlink) + { + self.events + .push_back(InternalEvent::ProcessHyperlink(mouse_down_hyperlink, true)); + self.selection_phase = SelectionPhase::Ended; + self.last_mouse = None; + self.mouse_down_position = None; + return; + } + + if self.mouse_mode(e.modifiers.shift) { + self.selection_phase = SelectionPhase::Ended; + self.last_mouse = None; + self.mouse_down_position = None; + return; + } + } + if self.mouse_mode(e.modifiers.shift) { let point = grid_point( position, @@ -2536,24 +2585,6 @@ impl Terminal { self.copy(Some(true)); } - if let Some(mouse_down_hyperlink) = self.mouse_down_hyperlink.take() { - let point = grid_point( - position, - self.last_content.terminal_bounds, - self.last_content.display_offset, - ); - - if let Some(mouse_up_hyperlink) = self.find_hyperlink_at_point(point) { - if mouse_down_hyperlink == mouse_up_hyperlink { - self.events - .push_back(InternalEvent::ProcessHyperlink(mouse_up_hyperlink, true)); - self.selection_phase = SelectionPhase::Ended; - self.last_mouse = None; - return; - } - } - } - //Hyperlinks if self.selection_phase == SelectionPhase::Ended { let mouse_cell_index = @@ -3586,6 +3617,7 @@ mod tests { ); terminal.last_content.terminal_bounds = terminal_bounds; terminal.events.clear(); + terminal.take_pty_write_log(); }); terminal @@ -3649,6 +3681,20 @@ mod tests { terminal.mouse_down(&mouse_down, cx); } + fn left_mouse_up_at( + terminal: &mut Terminal, + position: GpuiPoint, + cx: &mut Context, + ) { + let mouse_up = MouseUpEvent { + button: MouseButton::Left, + position, + modifiers: Modifiers::none(), + click_count: 1, + }; + terminal.mouse_up(&mouse_up, cx); + } + fn left_mouse_drag_to( terminal: &mut Terminal, position: GpuiPoint, @@ -4408,6 +4454,166 @@ mod tests { }); } + #[gpui::test] + async fn test_hyperlink_ctrl_click_same_position_in_mouse_mode(cx: &mut TestAppContext) { + let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n"); + + terminal.update(cx, |terminal, cx| { + terminal.last_content.mode = Modes::MOUSE_MODE; + + let click_position = point(px(80.0), px(10.0)); + ctrl_mouse_down_at(terminal, click_position, cx); + ctrl_mouse_up_at(terminal, click_position, cx); + + assert!( + terminal + .events + .iter() + .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, true))), + "Should have ProcessHyperlink event when ctrl+clicking on same hyperlink position in mouse mode" + ); + assert!( + terminal.take_pty_write_log().is_empty(), + "a consumed link click must not be reported to the PTY" + ); + }); + } + + #[gpui::test] + async fn test_hyperlink_ctrl_click_mismatch_in_mouse_mode_consumes_gesture( + cx: &mut TestAppContext, + ) { + let terminal = init_ctrl_click_hyperlink_test( + cx, + b"Visit https://zed.dev/ for more\r\nThis is another line\r\n", + ); + + terminal.update(cx, |terminal, cx| { + terminal.last_content.mode = Modes::MOUSE_MODE; + terminal.take_pty_write_log(); + + let down_position = point(px(80.0), px(10.0)); + let up_position = point(px(10.0), px(30.0)); + + ctrl_mouse_down_at(terminal, down_position, cx); + terminal.mouse_move( + &MouseMoveEvent { + position: up_position, + pressed_button: Some(MouseButton::Left), + modifiers: Modifiers::secondary_key(), + }, + cx, + ); + ctrl_mouse_up_at(terminal, up_position, cx); + + assert!( + !terminal + .events + .iter() + .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))), + "Should NOT open a link when press and release land on different hyperlinks" + ); + let pty_writes = terminal.take_pty_write_log(); + assert!( + pty_writes.is_empty(), + "a captured press must consume the whole gesture, but reports leaked to the PTY: {pty_writes:?}" + ); + }); + } + + #[gpui::test] + async fn test_plain_click_on_hyperlink_in_mouse_mode_is_reported(cx: &mut TestAppContext) { + let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n"); + + terminal.update(cx, |terminal, cx| { + terminal.last_content.mode = Modes::MOUSE_MODE; + terminal.take_pty_write_log(); + + let click_position = point(px(80.0), px(10.0)); + left_mouse_down_at(terminal, click_position, cx); + left_mouse_up_at(terminal, click_position, cx); + + assert!( + !terminal + .events + .iter() + .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))), + "a plain click must not open a link" + ); + let pty_writes = terminal.take_pty_write_log(); + assert_eq!( + pty_writes.len(), + 2, + "expected press and release reports, got {pty_writes:?}" + ); + }); + } + + #[gpui::test] + async fn test_ctrl_click_on_non_hyperlink_in_mouse_mode_is_reported(cx: &mut TestAppContext) { + let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n"); + + terminal.update(cx, |terminal, cx| { + terminal.last_content.mode = Modes::MOUSE_MODE; + terminal.take_pty_write_log(); + + // Past the end of the line: nothing link-like under the cursor. + let click_position = point(px(370.0), px(10.0)); + ctrl_mouse_down_at(terminal, click_position, cx); + ctrl_mouse_up_at(terminal, click_position, cx); + + assert!( + !terminal + .events + .iter() + .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))), + "a secondary click off a link must not open anything" + ); + let pty_writes = terminal.take_pty_write_log(); + assert_eq!( + pty_writes.len(), + 2, + "expected press and release reports, got {pty_writes:?}" + ); + }); + } + + #[gpui::test] + async fn test_ctrl_click_in_mouse_mode_forwards_when_setting_disabled(cx: &mut TestAppContext) { + let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n"); + + cx.update_global(|store: &mut settings::SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings + .terminal + .get_or_insert_default() + .open_links_in_mouse_mode = Some(false); + }); + }); + + terminal.update(cx, |terminal, cx| { + terminal.last_content.mode = Modes::MOUSE_MODE; + + let click_position = point(px(80.0), px(10.0)); + ctrl_mouse_down_at(terminal, click_position, cx); + ctrl_mouse_up_at(terminal, click_position, cx); + + assert!( + !terminal + .events + .iter() + .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))), + "with the setting disabled, ctrl+click must not open links in mouse mode" + ); + let pty_writes = terminal.take_pty_write_log(); + assert_eq!( + pty_writes.len(), + 2, + "expected press and release reports, got {pty_writes:?}" + ); + }); + } + #[gpui::test] async fn test_hyperlink_ctrl_click_drag_outside_bounds(cx: &mut TestAppContext) { let terminal = init_ctrl_click_hyperlink_test( diff --git a/crates/terminal/src/terminal_settings.rs b/crates/terminal/src/terminal_settings.rs index 8a31745d67d..64d76b7de3f 100644 --- a/crates/terminal/src/terminal_settings.rs +++ b/crates/terminal/src/terminal_settings.rs @@ -35,6 +35,7 @@ pub struct TerminalSettings { pub option_as_meta: bool, pub copy_on_select: bool, pub keep_selection_on_copy: bool, + pub open_links_in_mouse_mode: bool, pub button: bool, pub dock: TerminalDockPosition, pub flexible: bool, @@ -105,6 +106,7 @@ impl settings::Settings for TerminalSettings { option_as_meta: user_content.option_as_meta.unwrap(), copy_on_select: user_content.copy_on_select.unwrap(), keep_selection_on_copy: user_content.keep_selection_on_copy.unwrap(), + open_links_in_mouse_mode: user_content.open_links_in_mouse_mode.unwrap(), button: user_content.button.unwrap(), dock: user_content.dock.unwrap(), default_width: px(user_content.default_width.unwrap()), diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 1ca24ef7616..1450b58d6fc 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -4158,6 +4158,7 @@ List of `integer` column numbers "blinking": "terminal_controlled", "copy_on_select": false, "keep_selection_on_copy": true, + "open_links_in_mouse_mode": true, "dock": "bottom", "default_width": 640, "default_height": 320, @@ -4352,6 +4353,26 @@ List of `integer` column numbers } ``` +### Terminal: Open Links In Mouse Mode + +- Description: Whether cmd-click (ctrl-click on Linux and Windows) opens hyperlinks even when the terminal application has enabled mouse reporting (e.g. vim with `mouse=a`, htop). When `false`, these clicks are forwarded to the application instead, and hyperlinks can still be opened with shift-cmd-click (shift-ctrl-click). +- Setting: `open_links_in_mouse_mode` +- Default: `true` + +**Options** + +`boolean` values + +**Example** + +```json [settings] +{ + "terminal": { + "open_links_in_mouse_mode": false + } +} +``` + ### Terminal: Env - Description: Any key-value pairs added to this object will be added to the terminal's environment. Keys must be unique, use `:` to separate multiple values in a single variable diff --git a/docs/src/terminal.md b/docs/src/terminal.md index e4e876ab2db..f1e82ca0955 100644 --- a/docs/src/terminal.md +++ b/docs/src/terminal.md @@ -282,6 +282,16 @@ Common formats recognized: - `src/main.rs:42:10` — Opens at line 42, column 10 - `File "script.py", line 10` — Python tracebacks +By default, `Cmd+Click`/`Ctrl+Click` opens links even when the running application has enabled mouse reporting (e.g. vim with `mouse=a`, htop). If you prefer those clicks to be forwarded to the application instead, disable `open_links_in_mouse_mode`; links can then still be opened with `Shift+Cmd+Click` (`Shift+Ctrl+Click`): + +```json +{ + "terminal": { + "open_links_in_mouse_mode": false + } +} +``` + ## Panel Configuration ### Dock Position From a3a4719a8a246e2f4b88a1fdb0d94809726cad5f Mon Sep 17 00:00:00 2001 From: Vlad Roskov Date: Wed, 8 Jul 2026 16:06:10 +0300 Subject: [PATCH 141/197] project_panel: Open files in a permanent tab on middle click (#60563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective Provide a means of quickly opening a permanent tab from project panel instead of a preview tab. Implements #51866 (_Middle click to open file in a new tab_ with 18×↑) which is also part of #31822 (_Middle Click Improvments_) titled "middle clicking on a file in the project panel should open the file in a non-transient state". With `preview_tabs.enable_preview_from_project_panel` enabled, a click in the project panel opens a preview tab, and there's no easy gesture to open a permanent one instead, simplest one currently being a double click. VS Code and VSCode-based editors recognize middle mouse click as a way to open a permanent tab since 2016 (microsoft/vscode#14453). ## Solution Wired up an `on_aux_click` handler for project panel entries: middle-clicking a file opens it in a permanent tab and focuses it, regardless of the preview tabs setting. Added a line to `docs/src/project-panel.md` to reflect the behavior. ## Testing - Manually tested on Windows: middle click opens a permanent tab, promotes an existing preview tab to a permanent one if already open, does nothing for directories - Haven't tested on macOS/Linux ## Self-Review Checklist: - [ ] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase
Click to view showcase https://github.com/user-attachments/assets/2de5f23d-637f-4ffc-8d03-02dc11714af4
--- Release Notes: - Added support for middle-clicking a file in the project panel to open it in a permanent tab instead of a preview tab --- crates/project_panel/src/project_panel.rs | 10 ++++++++++ docs/src/project-panel.md | 2 ++ 2 files changed, 12 insertions(+) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 8b830513b2c..8b23423a973 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -6015,6 +6015,16 @@ impl ProjectPanel { } }), ) + .on_aux_click( + cx.listener(move |project_panel, event: &gpui::ClickEvent, _, cx| { + if !event.is_middle_click() || show_editor || !kind.is_file() { + return; + } + + project_panel.open_entry(entry_id, true, false, cx); + cx.stop_propagation(); + }), + ) .child( ListItem::new(id) .indent_level(depth) diff --git a/docs/src/project-panel.md b/docs/src/project-panel.md index afee88bbf36..030177af8f6 100644 --- a/docs/src/project-panel.md +++ b/docs/src/project-panel.md @@ -21,6 +21,8 @@ project_panel::CollapseAllEntries} collapses every directory at once. {#kb project_panel::ExpandAllEntries} expands every directory at once. Press {#kb project_panel::Open} or click to preview a selected file, without giving it a permanent tab. Editing the file or double-clicking it promotes it to a permanent tab. +Middle-clicking a file skips the preview and opens it in a permanent, focused tab +right away. ### Auto-reveal From 05530c9b354d487bda520a3c74bb02e90988b2fd Mon Sep 17 00:00:00 2001 From: Patryk <110726755+stemper-dev@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:44:41 +0700 Subject: [PATCH 142/197] acp_thread: Log error when checkpoint comparison fails (#59196) `update_last_checkpoint` swallows `compare_checkpoints` errors with `.unwrap_or(true)`. The "Restore checkpoint" button silently disappears and nothing gets logged, which is what made the linked issue painful to track down in the first place. The sibling `update_last_checkpoint_if_changed` a few lines up already handles the same call with `.context(...).log_err()` and an early return, so I did the same here. On error the checkpoint's visibility is left alone instead of being forced to hidden. I didn't propagate the error because the task result gets `?`'d in `run_turn`'s cleanup, and failing there would leave the panel stuck in its generating state. Added a regression test that breaks the comparison mid-turn (recreating `.git` makes the fake repo forget its checkpoints) and asserts an already-visible checkpoint stays visible. It fails on main and passes with this change. The new log line shows up when it runs: `failed to compare checkpoints: invalid left checkpoint: ...` Closes #59100 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 checkpoint comparison errors silently hiding the "Restore Checkpoint" button in the agent panel. Co-authored-by: pstemporowski <110726755+pstemporowski@users.noreply.github.com> Co-authored-by: Bennet Bo Fenner --- crates/acp_thread/src/acp_thread.rs | 88 ++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 3 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 2bbda6647af..9ec28e47d73 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -4127,12 +4127,16 @@ impl AcpThread { return Ok(()); }; - let equal = git_store + let Some(equal) = git_store .update(cx, |git, cx| { git.compare_checkpoints(old_checkpoint.clone(), new_checkpoint, cx) }) .await - .unwrap_or(true); + .context("failed to compare checkpoints") + .log_err() + else { + return Ok(()); + }; this.update(cx, |this, cx| { if let Some((ix, message)) = this.user_message_mut(&client_id) { @@ -4740,7 +4744,7 @@ mod tests { use gpui::UpdateGlobal as _; use gpui::{App, AsyncApp, TestAppContext, WeakEntity}; use indoc::indoc; - use project::{AgentId, FakeFs, Fs}; + use project::{AgentId, FakeFs, Fs, RemoveOptions}; use rand::{distr, prelude::*}; use serde_json::json; use settings::SettingsStore; @@ -9254,6 +9258,84 @@ mod tests { ); } + /// This is a regression test for a bug where update_last_checkpoint would + /// swallow a checkpoint comparison error and hide an already-visible + /// "Restore checkpoint" button without logging anything. + #[gpui::test] + async fn test_update_last_checkpoint_compare_error_keeps_checkpoint_visible( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/test"), json!({".git": {}, "file.txt": "content"})) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/test"))], cx).await; + + // The handler waits for this signal so the repository can be swapped + // out while the turn is still running. + let (complete_tx, complete_rx) = futures::channel::oneshot::channel::<()>(); + let complete_rx = RefCell::new(Some(complete_rx)); + let connection = Rc::new(FakeAgentConnection::new().on_user_message( + move |_, _thread, _cx| { + let complete_rx = complete_rx.borrow_mut().take(); + async move { + if let Some(rx) = complete_rx { + rx.await.ok(); + } + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) + } + .boxed_local() + }, + )); + + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap(); + + let send_future = thread.update(cx, |thread, cx| thread.send_raw("message", cx)); + let send_task = cx.background_executor.spawn(send_future); + cx.run_until_parked(); + + // Show the checkpoint, as update_last_checkpoint_if_changed does when + // files change during the turn. + thread.update(cx, |thread, _| { + let (_, message) = thread.last_user_message().unwrap(); + message.checkpoint.as_mut().unwrap().show = true; + }); + + // Recreate `.git` so the git store reopens the repository. The fresh + // fake repository doesn't contain the checkpoint recorded at send + // time, so the end-of-turn comparison fails. + fs.remove_dir( + Path::new(path!("/test/.git")), + RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .unwrap(); + cx.run_until_parked(); + fs.create_dir(Path::new(path!("/test/.git"))).await.unwrap(); + cx.run_until_parked(); + + complete_tx.send(()).unwrap(); + send_task.await.unwrap(); + cx.run_until_parked(); + + thread.update(cx, |thread, _| { + let (_, message) = thread.last_user_message().unwrap(); + assert!( + message.checkpoint.as_ref().unwrap().show, + "a checkpoint comparison failure must not hide the restore checkpoint button" + ); + }); + } + /// Tests that when a follow-up message is sent during generation, /// the first turn completing does NOT clear `running_turn` because /// it now belongs to the second turn. From 86b2831c7c2e210ce150b2771c89dc6bd70ae8a7 Mon Sep 17 00:00:00 2001 From: Neel Date: Wed, 8 Jul 2026 15:19:58 +0100 Subject: [PATCH 143/197] editor: Use raw head selection for rename (#60594) Follow up to https://github.com/zed-industries/zed/pull/55542, which reused the shifted offset for the rename input's *initial selection*, cutting it one character short. image image --- Release Notes: - Fixed symbol rename in vim mode omitting the last character of the symbol out of the rename input's initial selection --- crates/editor/src/editor.rs | 46 +++++++++++++++++++++---------------- crates/vim/src/helix.rs | 2 +- crates/vim/src/test.rs | 2 +- 3 files changed, 28 insertions(+), 22 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 96567329721..87589880b53 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -7707,16 +7707,21 @@ impl Editor { let cursor = self.rename_target_anchor(&selection, cx); let (cursor_buffer, cursor_buffer_position) = self.buffer.read(cx).text_anchor_for_position(cursor, cx)?; + let (head_buffer, head_buffer_position) = self + .buffer + .read(cx) + .text_anchor_for_position(selection.head(), cx)?; let (tail_buffer, cursor_buffer_position_end) = self .buffer .read(cx) .text_anchor_for_position(selection.tail(), cx)?; - if tail_buffer != cursor_buffer { + if tail_buffer != cursor_buffer || head_buffer != cursor_buffer { return None; } let snapshot = cursor_buffer.read(cx).snapshot(); let cursor_buffer_offset = cursor_buffer_position.to_offset(&snapshot); + let head_buffer_offset = head_buffer_position.to_offset(&snapshot); let cursor_buffer_offset_end = cursor_buffer_position_end.to_offset(&snapshot); let prepare_rename = provider.range_for_rename(&cursor_buffer, cursor_buffer_position, cx); drop(snapshot); @@ -7729,7 +7734,9 @@ impl Editor { let rename_buffer_range = rename_range.to_offset(&snapshot); let cursor_offset_in_rename_range = cursor_buffer_offset.saturating_sub(rename_buffer_range.start); - let cursor_offset_in_rename_range_end = + let head_offset_in_rename_range = + head_buffer_offset.saturating_sub(rename_buffer_range.start); + let tail_offset_in_rename_range = cursor_buffer_offset_end.saturating_sub(rename_buffer_range.start); this.take_rename(false, window, cx); @@ -7770,24 +7777,23 @@ impl Editor { cx, ) }); - let cursor_offset_in_rename_range = - MultiBufferOffset(cursor_offset_in_rename_range); - let cursor_offset_in_rename_range_end = - MultiBufferOffset(cursor_offset_in_rename_range_end); - let rename_selection_range = match cursor_offset_in_rename_range - .cmp(&cursor_offset_in_rename_range_end) - { - Ordering::Equal => { - editor.select_all(&SelectAll, window, cx); - return editor; - } - Ordering::Less => { - cursor_offset_in_rename_range..cursor_offset_in_rename_range_end - } - Ordering::Greater => { - cursor_offset_in_rename_range_end..cursor_offset_in_rename_range - } - }; + let head_offset_in_rename_range = + MultiBufferOffset(head_offset_in_rename_range); + let tail_offset_in_rename_range = + MultiBufferOffset(tail_offset_in_rename_range); + let rename_selection_range = + match head_offset_in_rename_range.cmp(&tail_offset_in_rename_range) { + Ordering::Equal => { + editor.select_all(&SelectAll, window, cx); + return editor; + } + Ordering::Less => { + head_offset_in_rename_range..tail_offset_in_rename_range + } + Ordering::Greater => { + tail_offset_in_rename_range..head_offset_in_rename_range + } + }; if rename_selection_range.end.0 > old_name.len() { editor.select_all(&SelectAll, window, cx); } else { diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index 79c65b227bb..2ab20aef36e 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -4350,7 +4350,7 @@ mod test { .set_request_handler::( move |_, params, _| async move { assert_eq!(params.position, expected_position); - Ok(Some(lsp::PrepareRenameResponse::Range(def_range))) + Ok(Some(lsp::PrepareRenameResponse::Range(tgt_range))) }, ); let mut rename_request = cx.set_request_handler::( diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index c426127dc07..4a874591f72 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -1272,7 +1272,7 @@ async fn test_visual_rename_uses_visible_cursor_position(cx: &mut gpui::TestAppC let mut prepare_request = cx.set_request_handler::( move |_, params, _| async move { assert_eq!(params.position, expected_position); - Ok(Some(lsp::PrepareRenameResponse::Range(def_range))) + Ok(Some(lsp::PrepareRenameResponse::Range(tgt_range))) }, ); let mut rename_request = From 74798c68d5c63d31e2ccca5c8f5ec0a02c90679c Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Wed, 8 Jul 2026 08:46:41 -0700 Subject: [PATCH 144/197] gpui: Add run_embedded and ApplicationHandle for externally driven run loops (#60574) On ordinary platforms, `Platform::run` blocks for the lifetime of the app, and `Application::run`'s stack frame keeps the app state alive. Embedded platforms invert that: the run loop belongs to someone else. `Application::run_embedded` supports that shape: it starts the app exactly like `run()`, but returns an `ApplicationHandle` holding the strong app handle. Release Notes: - N/A --- crates/gpui/src/app.rs | 47 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index e6ca25ecae0..0467c3e4fd8 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -143,6 +143,31 @@ impl Drop for AppRefMut<'_> { /// You won't interact with this type much outside of initial configuration and startup. pub struct Application(Rc); +/// A strong handle to an [`Application`] started with [`Application::run_embedded`]. +/// +/// Dropping this handle releases the app, so an embedder must hold it for as long as the +/// app should run. While held, it is the embedder's entry point back into GPUI each time +/// the external run loop gives it control. +pub struct ApplicationHandle { + app: Rc, +} + +impl ApplicationHandle { + /// Invoke `f` with the app context. Must not be called re-entrantly from code that + /// is already inside an update; the app state is a `RefCell` and will panic on a + /// double borrow. + pub fn update(&self, f: impl FnOnce(&mut App) -> R) -> R { + let cx = &mut *self.app.borrow_mut(); + f(cx) + } + + /// An [`AsyncApp`] for use across await points. It holds the app weakly; keeping the + /// app alive remains this handle's job. + pub fn to_async(&self) -> AsyncApp { + self.update(|cx| cx.to_async()) + } +} + /// Represents an application before it is fully launched. Once your app is /// configured, you'll start the app with `App::run`. impl Application { @@ -209,6 +234,28 @@ impl Application { })); } + /// Start the application for an embedder that drives the run loop itself. + /// + /// On ordinary platforms `Platform::run` blocks for the lifetime of the app, and the + /// app state is kept alive by [`Application::run`]'s stack frame. Embedded platforms — + /// where the run loop belongs to someone else, e.g. GPUI compiled into a Wasm guest, + /// or a GPUI view hosted inside a foreign native application — implement + /// `Platform::run` to invoke the launch callback and return immediately. This method + /// supports that shape: it returns an [`ApplicationHandle`] that keeps the app alive + /// and lets the embedder re-enter it whenever the external run loop yields control. + pub fn run_embedded(self, on_finish_launching: F) -> ApplicationHandle + where + F: 'static + FnOnce(&mut App), + { + let this = self.0.clone(); + let platform = self.0.borrow().platform.clone(); + platform.run(Box::new(move || { + let cx = &mut *this.borrow_mut(); + on_finish_launching(cx); + })); + ApplicationHandle { app: self.0 } + } + /// Register a handler to be invoked when the platform instructs the application /// to open one or more URLs. pub fn on_open_urls(&self, mut callback: F) -> &Self From c80020231bcc38c03db562d3c619dae20f1ab193 Mon Sep 17 00:00:00 2001 From: soddygo Date: Wed, 8 Jul 2026 23:52:26 +0800 Subject: [PATCH 145/197] agent_ui: Fix expanded message editor staying auto-height during streaming (#55916) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments (Unsafe: none) - [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 ## Summary When the agent is streaming output, expanding the message editor can appear to work (the container grows) but the input editor itself remains constrained to auto-height (only a few lines). Toggling minimize → expand makes it render correctly. This PR ensures the message editor stays in full mode while expanded, preventing automatic editor mode syncing on thread updates from overriding the user's explicit expand action during streaming. ## Steps to reproduce 1. Start an agent thread and send a message that causes streaming output 2. While streaming, click “Expand Message Editor” 3. Observe the input editor still shows only a few lines (auto-height) 4. Click “Minimize Message Editor” and then expand again; it becomes fully expanded ## Test plan - Start an agent thread and let it stream - Click “Expand Message Editor” while streaming - Verify the editor actually expands (not limited to the auto-height max lines) - Toggle minimize/expand multiple times during streaming; verify it remains correct ## Additional context Repro video: https://github.com/user-attachments/assets/6e328fa8-d431-456a-b70a-864ae617ea88 Release Notes: - Fixed message editor not fully expanding during agent generation --------- Co-authored-by: Smit Barmase --- crates/agent_ui/src/conversation_view.rs | 4 +- .../src/conversation_view/thread_view.rs | 38 +++++++------------ 2 files changed, 16 insertions(+), 26 deletions(-) diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 65632e9406b..110961b34f1 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -1614,7 +1614,7 @@ impl ConversationView { }); active.update(cx, |active, cx| { active.sync_elicitation_state_for_entry(index, window, cx); - active.sync_editor_mode_for_empty_state(cx); + active.sync_editor_mode(cx); active.sync_generating_indicator(cx); }); } @@ -1641,7 +1641,7 @@ impl ConversationView { entry_view_state.update(cx, |view_state, _cx| view_state.remove(range.clone())); list_state.splice(range.clone(), 0); active.update(cx, |active, cx| { - active.sync_editor_mode_for_empty_state(cx); + active.sync_editor_mode(cx); }); } } diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 90ea600ff5f..fe8b7b29233 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -1036,7 +1036,7 @@ impl ThreadView { }; this.sync_generating_indicator(cx); - this.sync_editor_mode_for_empty_state(cx); + this.sync_editor_mode(cx); this.sync_existing_elicitation_states(window, cx); let list_state_for_scroll = this.list_state.clone(); let thread_view = cx.entity().downgrade(); @@ -2373,27 +2373,7 @@ impl ThreadView { pub fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context) { self.editor_expanded = is_expanded; - self.message_editor.update(cx, |editor, cx| { - if is_expanded { - editor.set_mode( - EditorMode::Full { - scale_ui_elements_with_buffer_font_size: false, - show_active_line_background: false, - sizing_behavior: SizingBehavior::ExcludeOverscrollMargin, - }, - cx, - ) - } else { - let agent_settings = AgentSettings::get_global(cx); - editor.set_mode( - EditorMode::AutoHeight { - min_lines: agent_settings.message_editor_min_lines, - max_lines: Some(agent_settings.set_message_editor_max_lines()), - }, - cx, - ) - } - }); + self.sync_editor_mode(cx); cx.notify(); } @@ -7104,11 +7084,21 @@ impl ThreadView { open_markdown_in_workspace(thread_title, markdown, workspace, window, cx) } - pub(crate) fn sync_editor_mode_for_empty_state(&mut self, cx: &mut Context) { + pub(crate) fn sync_editor_mode(&mut self, cx: &mut Context) { let has_messages = self.list_state.item_count() > 0; let v2_empty_state = !has_messages; - let mode = if v2_empty_state { + if !has_messages { + self.editor_expanded = false; + } + + let mode = if self.editor_expanded { + EditorMode::Full { + scale_ui_elements_with_buffer_font_size: false, + show_active_line_background: false, + sizing_behavior: SizingBehavior::ExcludeOverscrollMargin, + } + } else if v2_empty_state { EditorMode::Full { scale_ui_elements_with_buffer_font_size: false, show_active_line_background: false, From 74b5207744ef95505fd75c56bf2c9d4736e931ee Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Wed, 8 Jul 2026 08:52:48 -0700 Subject: [PATCH 146/197] Unify Render and RenderOnce into View (#58087) This allows us to build powerful and flexible Input and TextArea components 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: Claude Opus 4.8 (1M context) --- crates/gpui/Cargo.toml | 4 + .../examples/view_example/example_editor.rs | 549 ++++++++++++++++++ .../examples/view_example/example_input.rs | 121 ++++ .../examples/view_example/example_tests.rs | 131 +++++ .../view_example/example_text_area.rs | 118 ++++ .../view_example/view_example_main.rs | 173 ++++++ crates/gpui/src/element.rs | 114 +--- crates/gpui/src/view.rs | 534 +++++++++++------ crates/gpui/src/window.rs | 8 +- crates/gpui_macros/src/derive_into_element.rs | 4 +- crates/gpui_macros/src/gpui_macros.rs | 4 +- crates/workspace/src/workspace.rs | 2 +- 12 files changed, 1464 insertions(+), 298 deletions(-) create mode 100644 crates/gpui/examples/view_example/example_editor.rs create mode 100644 crates/gpui/examples/view_example/example_input.rs create mode 100644 crates/gpui/examples/view_example/example_tests.rs create mode 100644 crates/gpui/examples/view_example/example_text_area.rs create mode 100644 crates/gpui/examples/view_example/view_example_main.rs diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index fa6fcace40f..bad1feabb3b 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -256,3 +256,7 @@ path = "examples/mouse_pressure.rs" [[example]] name = "a11y" path = "examples/a11y.rs" + +[[example]] +name = "view_example" +path = "examples/view_example/view_example_main.rs" diff --git a/crates/gpui/examples/view_example/example_editor.rs b/crates/gpui/examples/view_example/example_editor.rs new file mode 100644 index 00000000000..2064d7cacef --- /dev/null +++ b/crates/gpui/examples/view_example/example_editor.rs @@ -0,0 +1,549 @@ +//! `Editor` — the workhorse entity. It owns the cursor, blink, focus, keyboard +//! handling, and the specialized text-shaping renderer. The *text itself* lives +//! in a shared `Entity` it's handed at construction, so the value is +//! readable/writable from outside while the editing machinery stays in here. +//! +//! This is the piece that proves the point: a text input is genuinely +//! complicated, and `View` lets all of that complexity live in one entity that +//! anything can embed. + +use std::ops::Range; +use std::time::Duration; + +use gpui::{ + App, Bounds, Context, ElementInputHandler, Entity, EntityInputHandler, FocusHandle, Focusable, + InteractiveElement, LayoutId, PaintQuad, Pixels, ShapedLine, SharedString, Subscription, Task, + TextRun, UTF16Selection, Window, fill, hsla, point, prelude::*, px, relative, size, +}; +use unicode_segmentation::*; + +use crate::{Backspace, Delete, End, Home, Left, Right}; + +pub struct Editor { + pub value: Entity, + pub focus_handle: FocusHandle, + pub cursor: usize, + pub cursor_visible: bool, + _blink_task: Task<()>, + _subscriptions: Vec, +} + +impl Editor { + /// An editor that owns its own string internally, seeded with `text`. + /// Nothing to allocate or wire up at the call site. + pub fn new(text: impl Into, window: &mut Window, cx: &mut Context) -> Self { + let value = cx.new(|_| text.into()); + Self::over(value, window, cx) + } + + /// An editor over a string *you* own, so the value is shared in and out. + pub fn over(value: Entity, window: &mut Window, cx: &mut Context) -> Self { + let focus_handle = cx.focus_handle(); + + let focus_sub = cx.on_focus(&focus_handle, window, |this, _window, cx| { + this.start_blink(cx); + }); + let blur_sub = cx.on_blur(&focus_handle, window, |this, _window, cx| { + this.stop_blink(cx); + }); + + // The value is shared: anything can write it while we hold a cursor into + // it. Observe it so external writes (a) clamp the cursor back onto a char + // boundary before the next IME round-trip can slice out of bounds, and + // (b) notify us, so an `editor.cached(..)` subtree re-renders — the cache + // is keyed on *our* notify, not the value's. + let value_sub = cx.observe(&value, |this, value, cx| { + let content = value.read(cx); + let mut cursor = this.cursor.min(content.len()); + while cursor > 0 && !content.is_char_boundary(cursor) { + cursor -= 1; + } + this.cursor = cursor; + cx.notify(); + }); + + Self { + value, + focus_handle, + cursor: 0, + cursor_visible: false, + _blink_task: Task::ready(()), + _subscriptions: vec![focus_sub, blur_sub, value_sub], + } + } + + /// The current text. Read this from anywhere to get the value out. + pub fn text(&self, cx: &App) -> String { + self.value.read(cx).clone() + } + + fn start_blink(&mut self, cx: &mut Context) { + self.cursor_visible = true; + self._blink_task = Self::spawn_blink_task(cx); + } + + fn stop_blink(&mut self, cx: &mut Context) { + self.cursor_visible = false; + self._blink_task = Task::ready(()); + cx.notify(); + } + + fn spawn_blink_task(cx: &mut Context) -> Task<()> { + cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(500)) + .await; + let result = this.update(cx, |editor, cx| { + editor.cursor_visible = !editor.cursor_visible; + cx.notify(); + }); + if result.is_err() { + break; + } + } + }) + } + + fn reset_blink(&mut self, cx: &mut Context) { + self.cursor_visible = true; + self._blink_task = Self::spawn_blink_task(cx); + } + + pub fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context) { + let content = self.text(cx); + if self.cursor > 0 { + self.cursor = previous_boundary(&content, self.cursor); + } + self.reset_blink(cx); + cx.notify(); + } + + pub fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context) { + let content = self.text(cx); + if self.cursor < content.len() { + self.cursor = next_boundary(&content, self.cursor); + } + self.reset_blink(cx); + cx.notify(); + } + + pub fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context) { + self.cursor = 0; + self.reset_blink(cx); + cx.notify(); + } + + pub fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context) { + self.cursor = self.text(cx).len(); + self.reset_blink(cx); + cx.notify(); + } + + pub fn backspace(&mut self, _: &Backspace, _: &mut Window, cx: &mut Context) { + let content = self.text(cx); + if self.cursor > 0 { + let prev = previous_boundary(&content, self.cursor); + let cursor = self.cursor; + self.value.update(cx, |s, cx| { + s.drain(prev..cursor); + cx.notify(); + }); + self.cursor = prev; + } + self.reset_blink(cx); + cx.notify(); + } + + pub fn delete(&mut self, _: &Delete, _: &mut Window, cx: &mut Context) { + let content = self.text(cx); + if self.cursor < content.len() { + let next = next_boundary(&content, self.cursor); + let cursor = self.cursor; + self.value.update(cx, |s, cx| { + s.drain(cursor..next); + cx.notify(); + }); + } + self.reset_blink(cx); + cx.notify(); + } + + pub fn insert_newline(&mut self, cx: &mut Context) { + let cursor = self.cursor; + self.value.update(cx, |s, cx| { + s.insert(cursor, '\n'); + cx.notify(); + }); + self.cursor += 1; + self.reset_blink(cx); + cx.notify(); + } +} + +fn previous_boundary(content: &str, offset: usize) -> usize { + content + .grapheme_indices(true) + .rev() + .find_map(|(idx, _)| (idx < offset).then_some(idx)) + .unwrap_or(0) +} + +fn next_boundary(content: &str, offset: usize) -> usize { + content + .grapheme_indices(true) + .find_map(|(idx, _)| (idx > offset).then_some(idx)) + .unwrap_or(content.len()) +} + +fn offset_from_utf16(content: &str, offset: usize) -> usize { + let mut utf8_offset = 0; + let mut utf16_count = 0; + for ch in content.chars() { + if utf16_count >= offset { + break; + } + utf16_count += ch.len_utf16(); + utf8_offset += ch.len_utf8(); + } + utf8_offset +} + +fn offset_to_utf16(content: &str, offset: usize) -> usize { + let mut utf16_offset = 0; + let mut utf8_count = 0; + for ch in content.chars() { + if utf8_count >= offset { + break; + } + utf8_count += ch.len_utf8(); + utf16_offset += ch.len_utf16(); + } + utf16_offset +} + +fn range_to_utf16(content: &str, range: &Range) -> Range { + offset_to_utf16(content, range.start)..offset_to_utf16(content, range.end) +} + +fn range_from_utf16(content: &str, range_utf16: &Range) -> Range { + offset_from_utf16(content, range_utf16.start)..offset_from_utf16(content, range_utf16.end) +} + +impl Focusable for Editor { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl EntityInputHandler for Editor { + fn text_for_range( + &mut self, + range_utf16: Range, + actual_range: &mut Option>, + _window: &mut Window, + cx: &mut Context, + ) -> Option { + let content = self.text(cx); + let range = range_from_utf16(&content, &range_utf16); + actual_range.replace(range_to_utf16(&content, &range)); + Some(content[range].to_string()) + } + + fn selected_text_range( + &mut self, + _ignore_disabled_input: bool, + _window: &mut Window, + cx: &mut Context, + ) -> Option { + let content = self.text(cx); + let utf16_cursor = offset_to_utf16(&content, self.cursor); + Some(UTF16Selection { + range: utf16_cursor..utf16_cursor, + reversed: false, + }) + } + + fn marked_text_range( + &self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + None + } + + fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) {} + + fn replace_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + _window: &mut Window, + cx: &mut Context, + ) { + let content = self.text(cx); + let range = range_utf16 + .as_ref() + .map(|r| range_from_utf16(&content, r)) + .unwrap_or(self.cursor..self.cursor); + + let new_content = content[..range.start].to_owned() + new_text + &content[range.end..]; + self.cursor = range.start + new_text.len(); + self.value.update(cx, |s, cx| { + *s = new_content; + cx.notify(); + }); + self.reset_blink(cx); + cx.notify(); + } + + fn replace_and_mark_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + _new_selected_range_utf16: Option>, + window: &mut Window, + cx: &mut Context, + ) { + self.replace_text_in_range(range_utf16, new_text, window, cx); + } + + fn bounds_for_range( + &mut self, + _range_utf16: Range, + _bounds: Bounds, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + None + } + + fn character_index_for_point( + &mut self, + _point: gpui::Point, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + None + } +} + +impl gpui::Render for Editor { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + EditorText { + editor: cx.entity(), + } + } +} + +// --------------------------------------------------------------------------- +// EditorText — the specialized renderer: shapes the text and paints the cursor. +// --------------------------------------------------------------------------- + +struct EditorText { + editor: Entity, +} + +struct EditorTextPrepaint { + lines: Vec, + cursor: Option, +} + +impl IntoElement for EditorText { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for EditorText { + type RequestLayoutState = (); + type PrepaintState = EditorTextPrepaint; + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&gpui::GlobalElementId>, + _inspector_id: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let editor = self.editor.read(cx); + let content = editor.value.read(cx); + let line_count = content.split('\n').count().max(1); + let line_height = window.line_height(); + let mut style = gpui::Style::default(); + style.size.width = relative(1.).into(); + style.size.height = (line_height * line_count as f32).into(); + (window.request_layout(style, [], cx), ()) + } + + fn prepaint( + &mut self, + _id: Option<&gpui::GlobalElementId>, + _inspector_id: Option<&gpui::InspectorElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + let editor = self.editor.read(cx); + let content = editor.value.read(cx).clone(); + let cursor_offset = editor.cursor; + let cursor_visible = editor.cursor_visible; + let is_focused = editor.focus_handle.is_focused(window); + + let style = window.text_style(); + let text_color = style.color; + let font_size = style.font_size.to_pixels(window.rem_size()); + let line_height = window.line_height(); + + let is_placeholder = content.is_empty(); + + let lines: Vec = if is_placeholder { + let placeholder: SharedString = "Type here...".into(); + let run = TextRun { + len: placeholder.len(), + font: style.font(), + color: hsla(0., 0., 0.5, 0.5), + background_color: None, + underline: None, + strikethrough: None, + }; + vec![ + window + .text_system() + .shape_line(placeholder, font_size, &[run], None), + ] + } else { + content + .split('\n') + .map(|line_str| { + let text: SharedString = SharedString::from(line_str.to_string()); + let run = TextRun { + len: text.len(), + font: style.font(), + color: text_color, + background_color: None, + underline: None, + strikethrough: None, + }; + window + .text_system() + .shape_line(text, font_size, &[run], None) + }) + .collect() + }; + + let cursor = if is_focused && cursor_visible && !is_placeholder { + let (cursor_line, offset_in_line) = cursor_line_and_offset(&content, cursor_offset); + let cursor_line = cursor_line.min(lines.len().saturating_sub(1)); + let cursor_x = lines[cursor_line].x_for_index(offset_in_line); + Some(fill( + Bounds::new( + point( + bounds.left() + cursor_x, + bounds.top() + line_height * cursor_line as f32, + ), + size(px(1.5), line_height), + ), + text_color, + )) + } else if is_focused && cursor_visible && is_placeholder { + Some(fill( + Bounds::new( + point(bounds.left(), bounds.top()), + size(px(1.5), line_height), + ), + text_color, + )) + } else { + None + }; + + EditorTextPrepaint { lines, cursor } + } + + fn paint( + &mut self, + _id: Option<&gpui::GlobalElementId>, + _inspector_id: Option<&gpui::InspectorElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let focus_handle = self.editor.read(cx).focus_handle.clone(); + window.handle_input( + &focus_handle, + ElementInputHandler::new(bounds, self.editor.clone()), + cx, + ); + + let line_height = window.line_height(); + for (i, line) in prepaint.lines.iter().enumerate() { + let origin = point(bounds.left(), bounds.top() + line_height * i as f32); + line.paint(origin, line_height, gpui::TextAlign::Left, None, window, cx) + .unwrap(); + } + + if let Some(cursor) = prepaint.cursor.take() { + window.paint_quad(cursor); + } + } +} + +fn cursor_line_and_offset(content: &str, cursor: usize) -> (usize, usize) { + let mut line_index = 0; + let mut line_start = 0; + for (i, ch) in content.char_indices() { + if i >= cursor { + break; + } + if ch == '\n' { + line_index += 1; + line_start = i + 1; + } + } + (line_index, cursor - line_start) +} + +pub fn standard_actions(editor: Entity) -> impl FnOnce(E) -> E { + move |element| { + element + .on_action({ + let editor = editor.clone(); + move |a: &Left, window, cx| editor.update(cx, |e, cx| e.left(a, window, cx)) + }) + .on_action({ + let editor = editor.clone(); + move |a: &Right, window, cx| editor.update(cx, |e, cx| e.right(a, window, cx)) + }) + .on_action({ + let editor = editor.clone(); + move |a: &Home, window, cx| editor.update(cx, |e, cx| e.home(a, window, cx)) + }) + .on_action({ + let editor = editor.clone(); + move |a: &End, window, cx| editor.update(cx, |e, cx| e.end(a, window, cx)) + }) + .on_action({ + let editor = editor.clone(); + move |a: &Backspace, window, cx| { + editor.update(cx, |e, cx| e.backspace(a, window, cx)) + } + }) + .on_action(move |a: &Delete, window, cx| { + editor.update(cx, |e, cx| e.delete(a, window, cx)) + }) + } +} diff --git a/crates/gpui/examples/view_example/example_input.rs b/crates/gpui/examples/view_example/example_input.rs new file mode 100644 index 00000000000..25d74013deb --- /dev/null +++ b/crates/gpui/examples/view_example/example_input.rs @@ -0,0 +1,121 @@ +//! `Input` — a single-line text input. The shaping layer over `Editor`. +//! +//! Construct it two ways, depending on how much state you want to own: +//! * `Input::new(value: Entity)` — you hold just the string; the input +//! allocates the `Editor` internally via `use_state`. Value readable, cursor hidden. +//! * `Input::editor(editor: Entity)` — you hold the editor; cursor/selection +//! are now yours to read and drive too. +//! +//! Either way the chrome is identical. Because the string (or editor) is the +//! input's *identity*, the internal `use_state(Editor)` is collision-safe across +//! any number of inputs. + +use gpui::{ + App, BoxShadow, CursorStyle, Entity, EntityId, Hsla, IntoElement, Pixels, StyleRefinement, + Window, div, hsla, point, prelude::*, px, white, +}; + +use crate::example_editor::{Editor, standard_actions}; + +enum Source { + Value(Entity), + Editor(Entity), +} + +#[derive(IntoElement)] +pub struct Input { + source: Source, + width: Option, + color: Option, +} + +impl Input { + /// Backed by a bare string; the editor is allocated internally. + pub fn new(value: Entity) -> Self { + Self { + source: Source::Value(value), + width: None, + color: None, + } + } + + /// Backed by an editor you own (so you can read/drive its cursor). + pub fn editor(editor: Entity) -> Self { + Self { + source: Source::Editor(editor), + width: None, + color: None, + } + } + + pub fn width(mut self, width: Pixels) -> Self { + self.width = Some(width); + self + } + + pub fn color(mut self, color: Hsla) -> Self { + self.color = Some(color); + self + } +} + +impl gpui::View for Input { + fn entity_id(&self) -> Option { + Some(match &self.source { + Source::Value(value) => value.entity_id(), + Source::Editor(editor) => editor.entity_id(), + }) + } + + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + // Get the editor: use the one we were handed, or allocate it under our + // own (string-derived) identity so it persists and never collides. + let editor = match self.source { + Source::Value(value) => { + window.use_state(cx, move |window, cx| Editor::over(value, window, cx)) + } + Source::Editor(editor) => editor, + }; + + let focus_handle = editor.read(cx).focus_handle.clone(); + let is_focused = focus_handle.is_focused(window); + let text_color = self.color.unwrap_or(hsla(0., 0., 0.1, 1.)); + let box_width = self.width.unwrap_or(px(300.)); + + let border = if is_focused { + hsla(220. / 360., 0.8, 0.5, 1.) + } else { + hsla(0., 0., 0.75, 1.) + }; + + div() + .id("input") + .key_context("TextInput") + .track_focus(&focus_handle) + .cursor(CursorStyle::IBeam) + .map(standard_actions(editor.clone())) + .w(box_width) + .h(px(36.)) + .px(px(8.)) + .bg(white()) + .border_1() + .border_color(border) + .when(is_focused, |this| { + this.shadow(vec![BoxShadow { + color: hsla(220. / 360., 0.8, 0.5, 0.3), + offset: point(px(0.), px(0.)), + blur_radius: px(4.), + spread_radius: px(1.), + inset: false, + }]) + }) + .rounded(px(4.)) + .overflow_hidden() + .flex() + .items_center() + .line_height(px(20.)) + .text_size(px(14.)) + .text_color(text_color) + .child(editor.cached(StyleRefinement::default().size_full())) + } +} diff --git a/crates/gpui/examples/view_example/example_tests.rs b/crates/gpui/examples/view_example/example_tests.rs new file mode 100644 index 00000000000..a3edae8cda1 --- /dev/null +++ b/crates/gpui/examples/view_example/example_tests.rs @@ -0,0 +1,131 @@ +//! Tests for the input composition. Require the `test-support` feature: +//! +//! ```sh +//! cargo test -p gpui --example view_example --features test-support +//! ``` + +#[cfg(test)] +mod tests { + use gpui::{Context, Entity, KeyBinding, TestAppContext, Window, prelude::*}; + + use crate::example_editor::Editor; + use crate::example_input::Input; + use crate::{Backspace, Delete, End, Home, Left, Right}; + + /// Two inputs, each backed by an editor we own (so the test can focus and + /// read them). Proves data flows through the shared `String` and that + /// sibling inputs stay isolated. + struct Harness { + a: Entity, + b: Entity, + } + + impl Render for Harness { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + gpui::div() + .child(Input::editor(self.a.clone())) + .child(Input::editor(self.b.clone())) + } + } + + fn bind_keys(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.bind_keys([ + KeyBinding::new("backspace", Backspace, None), + KeyBinding::new("delete", Delete, None), + KeyBinding::new("left", Left, None), + KeyBinding::new("right", Right, None), + KeyBinding::new("home", Home, None), + KeyBinding::new("end", End, None), + ]); + }); + } + + fn setup( + cx: &mut TestAppContext, + ) -> ( + Entity, + Entity, + Entity, + &mut gpui::VisualTestContext, + ) { + bind_keys(cx); + + let (harness, cx) = cx.add_window_view(|window, cx| { + let a_value = cx.new(|_| String::new()); + let b_value = cx.new(|_| String::new()); + let a = cx.new(|cx| Editor::over(a_value, window, cx)); + let b = cx.new(|cx| Editor::over(b_value, window, cx)); + Harness { a, b } + }); + + let a = cx.read_entity(&harness, |h, _| h.a.clone()); + let b = cx.read_entity(&harness, |h, _| h.b.clone()); + let a_value = cx.read_entity(&a, |e, _| e.value.clone()); + let b_value = cx.read_entity(&b, |e, _| e.value.clone()); + + // Focus the first input's editor. + cx.update(|window, cx| { + let focus_handle = a.read(cx).focus_handle.clone(); + window.focus(&focus_handle, cx); + }); + + (a, a_value, b_value, cx) + } + + #[gpui::test] + fn typing_updates_the_shared_string(cx: &mut TestAppContext) { + let (editor, a_value, _b_value, cx) = setup(cx); + + cx.simulate_input("hello"); + + cx.read_entity(&a_value, |value, _| assert_eq!(value, "hello")); + cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 5)); + } + + #[gpui::test] + fn sibling_inputs_are_isolated(cx: &mut TestAppContext) { + let (_editor, a_value, b_value, cx) = setup(cx); + + cx.simulate_input("x"); + + cx.read_entity(&a_value, |value, _| assert_eq!(value, "x")); + cx.read_entity(&b_value, |value, _| { + assert_eq!(value, "", "typing in input A must not touch input B") + }); + } + + #[gpui::test] + fn external_writes_clamp_the_cursor(cx: &mut TestAppContext) { + let (editor, a_value, _b_value, cx) = setup(cx); + + cx.simulate_input("hello"); + cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 5)); + + // Write the shared value from outside the editor. The old cursor (5) + // now points into the middle of a multi-byte character; the editor's + // observation must clamp it back onto a boundary. + cx.update(|_, cx| { + a_value.update(cx, |value, cx| { + *value = "日本".to_string(); + cx.notify(); + }) + }); + + cx.read_entity(&a_value, |value, _| assert_eq!(value, "日本")); + cx.read_entity(&editor, |editor, _| { + assert_eq!(editor.cursor, 3, "cursor must clamp to a char boundary"); + }); + } + + #[gpui::test] + fn arrows_move_the_cursor(cx: &mut TestAppContext) { + let (editor, _a_value, _b_value, cx) = setup(cx); + + cx.simulate_input("abc"); + cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 3)); + + cx.simulate_keystrokes("left left"); + cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 1)); + } +} diff --git a/crates/gpui/examples/view_example/example_text_area.rs b/crates/gpui/examples/view_example/example_text_area.rs new file mode 100644 index 00000000000..07640b93294 --- /dev/null +++ b/crates/gpui/examples/view_example/example_text_area.rs @@ -0,0 +1,118 @@ +//! `TextArea` — a multi-line text box. Same `Editor` workhorse, taller chrome, +//! and `Enter` inserts a newline instead of being ignored. Constructible from a +//! string or an editor, exactly like [`Input`](crate::example_input::Input). + +use gpui::{ + App, BoxShadow, CursorStyle, Entity, EntityId, Hsla, IntoElement, StyleRefinement, Window, div, + hsla, point, prelude::*, px, white, +}; + +use crate::Enter; +use crate::example_editor::{Editor, standard_actions}; + +enum Source { + Value(Entity), + Editor(Entity), +} + +#[derive(IntoElement)] +pub struct TextArea { + source: Source, + rows: usize, + color: Option, +} + +impl TextArea { + pub fn new(value: Entity, rows: usize) -> Self { + Self { + source: Source::Value(value), + rows, + color: None, + } + } + + pub fn editor(editor: Entity, rows: usize) -> Self { + Self { + source: Source::Editor(editor), + rows, + color: None, + } + } + + pub fn color(mut self, color: Hsla) -> Self { + self.color = Some(color); + self + } +} + +impl gpui::View for TextArea { + fn entity_id(&self) -> Option { + Some(match &self.source { + Source::Value(value) => value.entity_id(), + Source::Editor(editor) => editor.entity_id(), + }) + } + + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let editor = match self.source { + Source::Value(value) => { + window.use_state(cx, move |window, cx| Editor::over(value, window, cx)) + } + Source::Editor(editor) => editor, + }; + + let focus_handle = editor.read(cx).focus_handle.clone(); + let is_focused = focus_handle.is_focused(window); + let text_color = self.color.unwrap_or(hsla(0., 0., 0.1, 1.)); + let row_height = px(20.); + let box_height = row_height * self.rows as f32 + px(16.); + + let border = if is_focused { + hsla(220. / 360., 0.8, 0.5, 1.) + } else { + hsla(0., 0., 0.75, 1.) + }; + + div() + .id("text-area") + .key_context("TextInput") + .track_focus(&focus_handle) + .cursor(CursorStyle::IBeam) + .map(standard_actions(editor.clone())) + // Enter is the one binding that differs from a single-line input. + .on_action({ + let editor = editor.clone(); + move |_: &Enter, _window, cx| editor.update(cx, |e, cx| e.insert_newline(cx)) + }) + .w(px(400.)) + .h(box_height) + .p(px(8.)) + .bg(white()) + .border_1() + .border_color(border) + .when(is_focused, |this| { + this.shadow(vec![BoxShadow { + color: hsla(220. / 360., 0.8, 0.5, 0.3), + offset: point(px(0.), px(0.)), + blur_radius: px(4.), + spread_radius: px(1.), + inset: false, + }]) + }) + .rounded(px(4.)) + .overflow_hidden() + .line_height(row_height) + .text_size(px(14.)) + .text_color(text_color) + // The cache style is computed from the `rows` prop: change `rows` and + // the editor's cached bounds change, busting its cache and re-laying + // out the text. (`Input` just uses `size_full()` — nothing to vary.) + .child( + editor.cached( + StyleRefinement::default() + .w_full() + .h(row_height * self.rows as f32), + ), + ) + } +} diff --git a/crates/gpui/examples/view_example/view_example_main.rs b/crates/gpui/examples/view_example/view_example_main.rs new file mode 100644 index 00000000000..0eac8494ffc --- /dev/null +++ b/crates/gpui/examples/view_example/view_example_main.rs @@ -0,0 +1,173 @@ +#![cfg_attr(target_family = "wasm", no_main)] + +//! View example — composing a text input from the `View` primitives. +//! +//! The whole point: a text input is deceptively complicated, and `View` makes it +//! easy to compose one. Three pieces, each shown in its own section: +//! +//! * `Editor` — the workhorse entity: cursor, blink, focus, keyboard, and a +//! specialized text renderer. All the hard parts live here. +//! * `String` — the data plane. `editor.text(cx)` / `value.read(cx)` get it out. +//! * `Input` / `TextArea` — the shaping layer. Each takes a `String` (and grows +//! the editor internally) OR an `Editor` (so you can read the cursor). +//! +//! Run: `cargo run -p gpui --example view_example` + +mod example_editor; +mod example_input; +mod example_text_area; + +#[cfg(test)] +mod example_tests; + +use example_editor::Editor; +use example_input::Input; +use example_text_area::TextArea; + +use gpui::{ + App, Bounds, Context, Div, Entity, IntoElement, KeyBinding, Render, SharedString, Window, + WindowBounds, WindowOptions, actions, div, hsla, prelude::*, px, rgb, size, +}; +use gpui_platform::application; + +actions!( + view_example, + [Backspace, Delete, Left, Right, Home, End, Enter, Quit] +); + +/// A tiny stateless view that reads an editor's cursor and is composed *beside* +/// the thing editing it — two views over one entity, zero wiring. +#[derive(IntoElement)] +struct CursorReadout { + editor: Entity, +} + +impl CursorReadout { + fn new(editor: Entity) -> Self { + Self { editor } + } +} + +impl gpui::RenderOnce for CursorReadout { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let cursor = self.editor.read(cx).cursor; + div() + .text_sm() + .text_color(hsla(0., 0., 0.45, 1.)) + .child(SharedString::from(format!("cursor @ {cursor}"))) + } +} + +struct ViewExample; + +impl ViewExample { + fn new() -> Self { + Self + } +} + +impl Render for ViewExample { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // The data plane: plain strings, allocated at the top by the hook. + let name = window.use_state(cx, |_, _| String::new()); + let email = window.use_state(cx, |_, _| String::from("me@example.com")); + let bio = window.use_state(cx, |_, _| String::new()); + // Editors that own their own string internally — no extra wiring up top. + let notes = window.use_state(cx, |window, cx| Editor::new("multi\nline", window, cx)); + let owned = window.use_state(cx, |window, cx| Editor::new("editable", window, cx)); + + div() + .flex() + .flex_col() + .size_full() + .bg(rgb(0xf0f0f0)) + .p(px(24.)) + .gap(px(24.)) + .child( + section("Inputs — from a String (cursor stays internal)") + .child(Input::new(name).width(px(320.))) + .child( + Input::new(email) + .width(px(320.)) + .color(hsla(0., 0., 0.3, 1.)), + ), + ) + .child( + section("Input — from an Editor (read its cursor beside it)").child( + div() + .flex() + .items_center() + .gap(px(12.)) + .child(Input::editor(owned.clone()).width(px(320.))) + .child(CursorReadout::new(owned)), + ), + ) + .child( + section("Text areas — from a String, or from an Editor") + .child(TextArea::new(bio, 3)) + .child( + div() + .flex() + .items_start() + .gap(px(12.)) + .child(TextArea::editor(notes.clone(), 3).color(hsla( + 250. / 360., + 0.7, + 0.4, + 1., + ))) + .child(CursorReadout::new(notes)), + ), + ) + } +} + +/// A labeled vertical section. +fn section(title: &str) -> Div { + div().flex().flex_col().gap(px(8.)).child( + div() + .text_sm() + .text_color(hsla(0., 0., 0.3, 1.)) + .child(SharedString::from(title.to_string())), + ) +} + +fn run_example() { + application().run(|cx: &mut App| { + let bounds = Bounds::centered(None, size(px(560.0), px(480.0)), cx); + cx.bind_keys([ + KeyBinding::new("backspace", Backspace, None), + KeyBinding::new("delete", Delete, None), + KeyBinding::new("left", Left, None), + KeyBinding::new("right", Right, None), + KeyBinding::new("home", Home, None), + KeyBinding::new("end", End, None), + KeyBinding::new("enter", Enter, None), + KeyBinding::new("cmd-q", Quit, None), + ]); + + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |_, cx| cx.new(|_| ViewExample::new()), + ) + .unwrap(); + + cx.on_action(|_: &Quit, cx| cx.quit()); + cx.activate(true); + }); +} + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/src/element.rs b/crates/gpui/src/element.rs index f212f246caa..dbd82b1c2ef 100644 --- a/crates/gpui/src/element.rs +++ b/crates/gpui/src/element.rs @@ -33,12 +33,12 @@ use crate::{ A11ySubtreeBuilder, App, ArenaBox, AvailableSpace, Bounds, Context, DispatchNodeId, ElementId, - FocusHandle, InspectorElementId, LayoutId, Pixels, Point, SharedString, Size, Style, Window, + FocusHandle, InspectorElementId, LayoutId, Pixels, Point, Size, Style, Window, util::FluentBuilder, window::with_element_arena, }; use derive_more::{Deref, DerefMut}; use std::{ - any::{Any, type_name}, + any::Any, fmt::{self, Debug, Display}, mem, panic, sync::Arc, @@ -208,116 +208,6 @@ pub trait ParentElement { } } -/// An element for rendering components. An implementation detail of the [`IntoElement`] derive macro -/// for [`RenderOnce`] -#[doc(hidden)] -pub struct Component { - component: Option, - #[cfg(debug_assertions)] - source: &'static core::panic::Location<'static>, -} - -impl Component { - /// Create a new component from the given RenderOnce type. - #[track_caller] - pub fn new(component: C) -> Self { - Component { - component: Some(component), - #[cfg(debug_assertions)] - source: core::panic::Location::caller(), - } - } -} - -fn prepaint_component( - (element, name): &mut (AnyElement, &'static str), - window: &mut Window, - cx: &mut App, -) { - window.with_id(ElementId::Name(SharedString::new_static(name)), |window| { - element.prepaint(window, cx); - }) -} - -fn paint_component( - (element, name): &mut (AnyElement, &'static str), - window: &mut Window, - cx: &mut App, -) { - window.with_id(ElementId::Name(SharedString::new_static(name)), |window| { - element.paint(window, cx); - }) -} -impl Element for Component { - type RequestLayoutState = (AnyElement, &'static str); - type PrepaintState = (); - - fn id(&self) -> Option { - None - } - - fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { - #[cfg(debug_assertions)] - return Some(self.source); - - #[cfg(not(debug_assertions))] - return None; - } - - fn request_layout( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - window.with_id(ElementId::Name(type_name::().into()), |window| { - let mut element = self - .component - .take() - .unwrap() - .render(window, cx) - .into_any_element(); - - let layout_id = element.request_layout(window, cx); - (layout_id, (element, type_name::())) - }) - } - - fn prepaint( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - _: Bounds, - state: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) { - prepaint_component(state, window, cx); - } - - fn paint( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - _: Bounds, - state: &mut Self::RequestLayoutState, - _: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - paint_component(state, window, cx); - } -} - -impl IntoElement for Component { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } -} - /// A globally unique identifier for an element, used to track state across frames. #[derive(Deref, DerefMut, Clone, Default, Debug, Eq, PartialEq, Hash)] pub struct GlobalElementId(pub(crate) Arc<[ElementId]>); diff --git a/crates/gpui/src/view.rs b/crates/gpui/src/view.rs index 39b87dbb803..394f00c1082 100644 --- a/crates/gpui/src/view.rs +++ b/crates/gpui/src/view.rs @@ -1,36 +1,24 @@ use crate::{ AnyElement, AnyEntity, AnyWeakEntity, App, Bounds, ContentMask, Context, Element, ElementId, Entity, EntityId, GlobalElementId, InspectorElementId, IntoElement, LayoutId, PaintIndex, - Pixels, PrepaintStateIndex, Render, Style, StyleRefinement, TextStyle, WeakEntity, + Pixels, PrepaintStateIndex, Render, RenderOnce, Style, StyleRefinement, TextStyle, WeakEntity, }; use crate::{Empty, Window}; use anyhow::Result; use collections::FxHashSet; use refineable::Refineable; use std::mem; -use std::rc::Rc; use std::{any::TypeId, fmt, ops::Range}; -struct AnyViewState { - prepaint_range: Range, - paint_range: Range, - cache_key: ViewCacheKey, - accessed_entities: FxHashSet, -} - -#[derive(Default)] -struct ViewCacheKey { - bounds: Bounds, - content_mask: ContentMask, - text_style: TextStyle, -} - -/// A dynamically-typed handle to a view, which can be downcast to a [Entity] for a specific type. +/// A dynamically-typed view handle that can be downcast to a specific `Entity`. +/// +/// This is the type-erased counterpart to [`ViewElement`]: it holds an entity plus +/// a function pointer to its render, and is itself a [`View`], so embedding it as an +/// element goes through the same [`ViewElement`] machinery as any other view. #[derive(Clone, Debug)] pub struct AnyView { entity: AnyEntity, render: fn(&AnyView, &mut Window, &mut App) -> AnyElement, - cached_style: Option>, } impl From> for AnyView { @@ -38,18 +26,18 @@ impl From> for AnyView { AnyView { entity: value.into_any(), render: any_view::render::, - cached_style: None, } } } impl AnyView { - /// Indicate that this view should be cached when using it as an element. - /// When using this method, the view's previous layout and paint will be recycled from the previous frame if [Context::notify] has not been called since it was rendered. - /// The one exception is when [Window::refresh] is called, in which case caching is ignored. - pub fn cached(mut self, style: StyleRefinement) -> Self { - self.cached_style = Some(style.into()); - self + /// Embed this view as a cached [`ViewElement`] laid out at `style`. + /// + /// The rendered subtree is recycled from the previous frame unless + /// [Context::notify] was called on the backing entity since it was rendered + /// (or [Window::refresh] is called, which ignores caching). + pub fn cached(self, style: StyleRefinement) -> ViewElement { + ViewElement::new(self).cached(style) } /// Convert this to a weak handle. @@ -68,7 +56,6 @@ impl AnyView { Err(entity) => Err(Self { entity, render: self.render, - cached_style: self.cached_style, }), } } @@ -78,7 +65,7 @@ impl AnyView { self.entity.entity_type } - /// Gets the entity id of this handle. + /// The [`EntityId`] of this view. pub fn entity_id(&self) -> EntityId { self.entity.entity_id() } @@ -92,184 +79,48 @@ impl PartialEq for AnyView { impl Eq for AnyView {} -impl Element for AnyView { - type RequestLayoutState = Option; - type PrepaintState = Option; - - fn id(&self) -> Option { - Some(ElementId::View(self.entity_id())) +/// `AnyView` is the type-erased [`View`]: its `render` is a function pointer rather +/// than a concrete type, but it participates in the reactive graph exactly like any +/// other view via [`ViewElement`]. +impl View for AnyView { + fn entity_id(&self) -> Option { + Some(self.entity.entity_id()) } - fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - window.with_rendered_view(self.entity_id(), |window| { - // Disable caching when inspecting so that mouse_hit_test has all hitboxes. - let caching_disabled = window.is_inspector_picking(cx); - match self.cached_style.as_ref() { - Some(style) if !caching_disabled => { - let mut root_style = Style::default(); - root_style.refine(style); - let layout_id = window.request_layout(root_style, None, cx); - (layout_id, None) - } - _ => { - let mut element = (self.render)(self, window, cx); - let layout_id = element.request_layout(window, cx); - (layout_id, Some(element)) - } - } - }) - } - - fn prepaint( - &mut self, - global_id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - bounds: Bounds, - element: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Option { - window.set_view_id(self.entity_id()); - window.with_rendered_view(self.entity_id(), |window| { - if let Some(mut element) = element.take() { - element.prepaint(window, cx); - return Some(element); - } - - window.with_element_state::( - global_id.unwrap(), - |element_state, window| { - let content_mask = window.content_mask(); - let text_style = window.text_style(); - - if let Some(mut element_state) = element_state - && element_state.cache_key.bounds == bounds - && element_state.cache_key.content_mask == content_mask - && element_state.cache_key.text_style == text_style - && !window.dirty_views.contains(&self.entity_id()) - && !window.refreshing - { - let prepaint_start = window.prepaint_index(); - window.reuse_prepaint(element_state.prepaint_range.clone()); - cx.entities - .extend_accessed(&element_state.accessed_entities); - let prepaint_end = window.prepaint_index(); - element_state.prepaint_range = prepaint_start..prepaint_end; - - return (None, element_state); - } - - let refreshing = mem::replace(&mut window.refreshing, true); - let prepaint_start = window.prepaint_index(); - let (mut element, accessed_entities) = cx.detect_accessed_entities(|cx| { - let mut element = (self.render)(self, window, cx); - element.layout_as_root(bounds.size.into(), window, cx); - element.prepaint_at(bounds.origin, window, cx); - element - }); - - let prepaint_end = window.prepaint_index(); - window.refreshing = refreshing; - - ( - Some(element), - AnyViewState { - accessed_entities, - prepaint_range: prepaint_start..prepaint_end, - paint_range: PaintIndex::default()..PaintIndex::default(), - cache_key: ViewCacheKey { - bounds, - content_mask, - text_style, - }, - }, - ) - }, - ) - }) - } - - fn paint( - &mut self, - global_id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - _bounds: Bounds, - _: &mut Self::RequestLayoutState, - element: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - window.with_rendered_view(self.entity_id(), |window| { - let caching_disabled = window.is_inspector_picking(cx); - if self.cached_style.is_some() && !caching_disabled { - window.with_element_state::( - global_id.unwrap(), - |element_state, window| { - let mut element_state = element_state.unwrap(); - - let paint_start = window.paint_index(); - - if let Some(element) = element { - let refreshing = mem::replace(&mut window.refreshing, true); - element.paint(window, cx); - window.refreshing = refreshing; - } else { - window.reuse_paint(element_state.paint_range.clone()); - } - - let paint_end = window.paint_index(); - element_state.paint_range = paint_start..paint_end; - - ((), element_state) - }, - ) - } else { - element.as_mut().unwrap().paint(window, cx); - } - }); + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + (self.render)(&self, window, cx) } } impl IntoElement for Entity { - type Element = AnyView; + type Element = ViewElement>; fn into_element(self) -> Self::Element { - self.into() + ViewElement::new(self) } } impl IntoElement for AnyView { - type Element = Self; + type Element = ViewElement; fn into_element(self) -> Self::Element { - self + ViewElement::new(self) } } -/// A weak, dynamically-typed view handle that does not prevent the view from being released. +/// A weak, dynamically-typed view handle. pub struct AnyWeakView { entity: AnyWeakEntity, render: fn(&AnyView, &mut Window, &mut App) -> AnyElement, } impl AnyWeakView { - /// Convert to a strongly-typed handle if the referenced view has not yet been released. + /// Upgrade to a strong `AnyView` handle, if the view is still alive. pub fn upgrade(&self) -> Option { let entity = self.entity.upgrade()?; Some(AnyView { entity, render: self.render, - cached_style: None, }) } } @@ -310,6 +161,335 @@ mod any_view { } } +/// A renderable that participates in GPUI's reactive graph — the unifying model +/// behind [`Render`] and [`RenderOnce`]. +/// +/// When `entity_id()` returns `Some`, that id becomes the view's identity: it gets +/// a unique element-id space (so internal `use_state` / `.id(..)` never collide +/// across siblings) and `cx.notify()` on that entity re-renders only this view's +/// subtree. `None` behaves like a stateless component. +/// +/// You rarely implement `View` directly. `Entity` and any `T: RenderOnce` +/// get a blanket impl below; implement it by hand only when a component needs both +/// parent-supplied props *and* a backing entity for identity. +pub trait View: 'static + Sized { + /// This view's identity, if it has one. A view typically holds the backing + /// entity as a field and returns its [`EntityId`] here. + /// + /// The id becomes this view's [`ElementId`], so two views keyed on the same + /// entity must not be rendered at the same position in the element tree + /// (e.g. as siblings under the same parent): their internal element state + /// (`use_state`, scroll offsets, etc.) would silently collide. Nesting is + /// fine — the id is scoped by the parent path. + fn entity_id(&self) -> Option; + + /// Render this view into an element tree, consuming `self`. + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement; +} + +/// A stateless component (`RenderOnce`) is a `View` with no identity. +impl View for T { + fn entity_id(&self) -> Option { + None + } + + #[inline] + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + RenderOnce::render(self, window, cx) + } +} + +/// An entity that renders itself (`Render`) is a `View` keyed on its own id. +impl View for Entity { + fn entity_id(&self) -> Option { + Some(Entity::entity_id(self)) + } + + #[inline] + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + self.update(cx, |this, cx| { + Render::render(this, window, cx).into_any_element() + }) + } +} + +impl Entity { + /// Embed this entity as a cached [`ViewElement`] laid out at `style`. + /// + /// The rendered subtree is reused until the entity is notified (or the + /// cached bounds / text style change). Caching requires a definite size: + /// a cached view is laid out from `style` and is *not* measured from its + /// contents. Use [`ViewElement::new`] (or `.child(entity)`) for the + /// uncached case. + #[track_caller] + pub fn cached(self, style: StyleRefinement) -> ViewElement> { + ViewElement::new(self).cached(style) + } +} + +/// The element type for [`View`] implementations. Wraps a `View` and hooks it +/// into layout, prepaint, and paint. Constructed via [`ViewElement::new`]. +#[doc(hidden)] +pub struct ViewElement { + view: Option, + entity_id: Option, + cached_style: Option, + #[cfg(debug_assertions)] + source: &'static core::panic::Location<'static>, +} + +impl ViewElement { + /// Wrap a [`View`] as an element. + #[track_caller] + pub fn new(view: V) -> Self { + let entity_id = view.entity_id(); + ViewElement { + entity_id, + cached_style: None, + view: Some(view), + #[cfg(debug_assertions)] + source: core::panic::Location::caller(), + } + } + + /// Enable caching of this view's rendered subtree, laid out at `style`. + /// The composer supplies the layout style because caching skips rendering + /// the contents to measure them. + /// + /// Crate-private on purpose: caching is only sound for entity-backed views, + /// where [`Context::notify`] is the contract that busts the cache. A stateless + /// view has no such contract, so a frozen subtree could never be invalidated. + /// Reach this through [`Entity::cached`] or [`AnyView::cached`], which are + /// entity-backed by construction. + pub(crate) fn cached(mut self, style: StyleRefinement) -> Self { + self.cached_style = Some(style); + self + } +} + +impl IntoElement for ViewElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +struct ViewElementState { + prepaint_range: Range, + paint_range: Range, + cache_key: ViewElementCacheKey, + accessed_entities: FxHashSet, +} + +struct ViewElementCacheKey { + bounds: Bounds, + content_mask: ContentMask, + text_style: TextStyle, +} + +impl Element for ViewElement { + type RequestLayoutState = Option; + type PrepaintState = Option; + + fn id(&self) -> Option { + self.entity_id.map(ElementId::View) + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + #[cfg(debug_assertions)] + return Some(self.source); + + #[cfg(not(debug_assertions))] + return None; + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + if let Some(entity_id) = self.entity_id { + // Stateful path: create a reactive boundary. + window.with_rendered_view(entity_id, |window| { + let caching_disabled = window.is_inspector_picking(cx); + match self.cached_style.as_ref() { + Some(style) if !caching_disabled => { + let mut root_style = Style::default(); + root_style.refine(style); + let layout_id = window.request_layout(root_style, None, cx); + (layout_id, None) + } + _ => { + let mut element = self + .view + .take() + .unwrap() + .render(window, cx) + .into_any_element(); + let layout_id = element.request_layout(window, cx); + (layout_id, Some(element)) + } + } + }) + } else { + // Stateless path: isolate subtree via type name (no entity identity). + window.with_id( + ElementId::Name(std::any::type_name::().into()), + |window| { + let mut element = self + .view + .take() + .unwrap() + .render(window, cx) + .into_any_element(); + let layout_id = element.request_layout(window, cx); + (layout_id, Some(element)) + }, + ) + } + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + element: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Option { + if let Some(entity_id) = self.entity_id { + // Stateful path. + window.set_view_id(entity_id); + window.with_rendered_view(entity_id, |window| { + if let Some(mut element) = element.take() { + element.prepaint(window, cx); + return Some(element); + } + + window.with_element_state::( + global_id.unwrap(), + |element_state, window| { + let content_mask = window.content_mask(); + let text_style = window.text_style(); + + if let Some(mut element_state) = element_state + && element_state.cache_key.bounds == bounds + && element_state.cache_key.content_mask == content_mask + && element_state.cache_key.text_style == text_style + && !window.dirty_views.contains(&entity_id) + && !window.refreshing + { + let prepaint_start = window.prepaint_index(); + window.reuse_prepaint(element_state.prepaint_range.clone()); + cx.entities + .extend_accessed(&element_state.accessed_entities); + let prepaint_end = window.prepaint_index(); + element_state.prepaint_range = prepaint_start..prepaint_end; + + return (None, element_state); + } + + let refreshing = mem::replace(&mut window.refreshing, true); + let prepaint_start = window.prepaint_index(); + let (mut element, accessed_entities) = cx.detect_accessed_entities(|cx| { + let mut element = self + .view + .take() + .unwrap() + .render(window, cx) + .into_any_element(); + element.layout_as_root(bounds.size.into(), window, cx); + element.prepaint_at(bounds.origin, window, cx); + element + }); + + let prepaint_end = window.prepaint_index(); + window.refreshing = refreshing; + + ( + Some(element), + ViewElementState { + accessed_entities, + prepaint_range: prepaint_start..prepaint_end, + paint_range: PaintIndex::default()..PaintIndex::default(), + cache_key: ViewElementCacheKey { + bounds, + content_mask, + text_style, + }, + }, + ) + }, + ) + }) + } else { + // Stateless path: just prepaint the element. + window.with_id( + ElementId::Name(std::any::type_name::().into()), + |window| { + element.as_mut().unwrap().prepaint(window, cx); + }, + ); + Some(element.take().unwrap()) + } + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + element: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + if let Some(entity_id) = self.entity_id { + // Stateful path. + window.with_rendered_view(entity_id, |window| { + let caching_disabled = window.is_inspector_picking(cx); + if self.cached_style.is_some() && !caching_disabled { + window.with_element_state::( + global_id.unwrap(), + |element_state, window| { + let mut element_state = element_state.unwrap(); + + let paint_start = window.paint_index(); + + if let Some(element) = element { + let refreshing = mem::replace(&mut window.refreshing, true); + element.paint(window, cx); + window.refreshing = refreshing; + } else { + window.reuse_paint(element_state.paint_range.clone()); + } + + let paint_end = window.paint_index(); + element_state.paint_range = paint_start..paint_end; + + ((), element_state) + }, + ) + } else { + element.as_mut().unwrap().paint(window, cx); + } + }); + } else { + // Stateless path: just paint the element. + window.with_id( + ElementId::Name(std::any::type_name::().into()), + |window| { + element.as_mut().unwrap().paint(window, cx); + }, + ); + } + } +} + /// A view that renders nothing pub struct EmptyView; diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index f4bd64ecba5..62a8011d4e6 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -2824,7 +2824,7 @@ impl Window { }; // Layout all root elements. - let mut root_element = self.root.as_ref().unwrap().clone().into_any(); + let mut root_element = self.root.as_ref().unwrap().clone().into_any_element(); root_element.prepaint_as_root(Point::default(), root_size.into(), self, cx); #[cfg(any(feature = "inspector", debug_assertions))] @@ -2836,12 +2836,12 @@ impl Window { let mut active_drag_element = None; let mut tooltip_element = None; if let Some(prompt) = self.prompt.take() { - let mut element = prompt.view.any_view().into_any(); + let mut element = prompt.view.any_view().into_any_element(); element.prepaint_as_root(Point::default(), root_size.into(), self, cx); prompt_element = Some(element); self.prompt = Some(prompt); } else if let Some(active_drag) = cx.active_drag.take() { - let mut element = active_drag.view.clone().into_any(); + let mut element = active_drag.view.clone().into_any_element(); let offset = self.mouse_position() - active_drag.cursor_offset; element.prepaint_as_root(offset, AvailableSpace::min_size(), self, cx); active_drag_element = Some(element); @@ -2905,7 +2905,7 @@ impl Window { log::error!("Unexpectedly absent TooltipRequest"); continue; }; - let mut element = tooltip_request.tooltip.view.clone().into_any(); + let mut element = tooltip_request.tooltip.view.clone().into_any_element(); let mouse_position = tooltip_request.tooltip.mouse_position; let tooltip_size = element.layout_as_root(AvailableSpace::min_size(), self, cx); diff --git a/crates/gpui_macros/src/derive_into_element.rs b/crates/gpui_macros/src/derive_into_element.rs index 89d609ae65d..51d2a8ab3f7 100644 --- a/crates/gpui_macros/src/derive_into_element.rs +++ b/crates/gpui_macros/src/derive_into_element.rs @@ -11,11 +11,11 @@ pub fn derive_into_element(input: TokenStream) -> TokenStream { impl #impl_generics gpui::IntoElement for #type_name #type_generics #where_clause { - type Element = gpui::Component; + type Element = gpui::ViewElement; #[track_caller] fn into_element(self) -> Self::Element { - gpui::Component::new(self) + gpui::ViewElement::new(self) } } }; diff --git a/crates/gpui_macros/src/gpui_macros.rs b/crates/gpui_macros/src/gpui_macros.rs index f3958bca568..50305af752c 100644 --- a/crates/gpui_macros/src/gpui_macros.rs +++ b/crates/gpui_macros/src/gpui_macros.rs @@ -29,8 +29,8 @@ pub fn register_action(ident: TokenStream) -> TokenStream { register_action::register_action(ident) } -/// #[derive(IntoElement)] is used to create a Component out of anything that implements -/// the `RenderOnce` trait. +/// #[derive(IntoElement)] generates an `IntoElement` impl for any `RenderOnce` +/// type, wrapping it in a `ViewElement` so it can be used as a child. #[proc_macro_derive(IntoElement)] pub fn derive_into_element(input: TokenStream) -> TokenStream { derive_into_element::derive_into_element(input) diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index dd88d767413..0fc345b1b7c 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -6294,7 +6294,7 @@ impl Workspace { .children( self.notifications .iter() - .map(|(_, notification)| notification.clone().into_any()), + .map(|(_, notification)| notification.clone().into_any_element()), ), ) } From d9e35975c238013d2888c5ef8231c9f3d786c993 Mon Sep 17 00:00:00 2001 From: Oleksandr Kholiavko <43780952+HalavicH@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:54:36 +0200 Subject: [PATCH 147/197] csv_preview: Add row filtering feature (#60339) # Objective CSV feature needs row filtering feature by column values. This PR provides base implementation of it with barebones ui. > NOTE: Sleek UI with search & proper scrolling hanling is implemented in next PR. It's stacked on top to reduce review scope ## Solution - New `FilterEntry` / `FilterEntryState` model in `table_data_engine/filtering_by_column.rs` tracking per-column applied/candidate filter values - Filtering runs in the background (`feat: Implement background filtering`) so large CSVs don't block the UI thread while a filter is applied - Filter menu entries reflect live counts and support a configurable sort order (`FilterSortOrder`, added in `renderer/settings.rs` / `settings.rs`) - Filter/sort trigger buttons on column headers are hidden until hover, using `GradientFade` (new in `ui/src/components/gradient_fade.rs`) to fade content behind them ## Testing Filter chain tested on csv fixtures with multiple filters applied sequentially columns. ## Self-Review Checklist: (todo) - [ ] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [ ] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) (out of scope of this pr) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase image image --- Release Notes: - Added initial row filtering UI & logic --- crates/csv_preview/src/csv_preview.rs | 70 +++-- crates/csv_preview/src/parser.rs | 9 +- .../renderer/performance_metrics_overlay.rs | 2 +- .../csv_preview/src/renderer/preview_view.rs | 16 +- .../src/renderer/row_identifiers.rs | 1 + crates/csv_preview/src/renderer/settings.rs | 53 +++- .../csv_preview/src/renderer/table_header.rs | 199 +++++++++++++- crates/csv_preview/src/settings.rs | 14 +- crates/csv_preview/src/table_data_engine.rs | 62 +++-- .../table_data_engine/filtering_by_column.rs | 250 ++++++++++++++++++ crates/gpui/src/elements/list.rs | 38 +++ 11 files changed, 668 insertions(+), 46 deletions(-) create mode 100644 crates/csv_preview/src/table_data_engine/filtering_by_column.rs diff --git a/crates/csv_preview/src/csv_preview.rs b/crates/csv_preview/src/csv_preview.rs index 322777da042..8f8ba768754 100644 --- a/crates/csv_preview/src/csv_preview.rs +++ b/crates/csv_preview/src/csv_preview.rs @@ -8,7 +8,7 @@ use std::{ time::{Duration, Instant}, }; -use crate::table_data_engine::TableDataEngine; +use crate::table_data_engine::{DisplayToDataMapping, TableDataEngine}; use ui::{ AbsoluteLength, ResizableColumnsState, SharedString, TableInteractionState, TableResizeBehavior, prelude::*, @@ -41,6 +41,10 @@ pub struct CsvPreviewView { pub(crate) table_interaction_state: Entity, pub(crate) column_widths: ColumnWidths, pub(crate) parsing_task: Option>>, + pub(crate) is_parsing: bool, + /// Background task computing the display-to-data mapping after a filter/sort change. + /// Stored here so that a new change cancels the previous in-flight computation. + pub(crate) filter_sort_task: Option>, pub(crate) settings: CsvPreviewSettings, /// Performance metrics for debugging and monitoring CSV operations. pub(crate) performance_metrics: PerformanceMetrics, @@ -178,9 +182,11 @@ impl CsvPreviewView { table_interaction_state, column_widths: ColumnWidths::new(cx, 1), parsing_task: None, + is_parsing: false, + filter_sort_task: None, performance_metrics: PerformanceMetrics::default(), list_state: gpui::ListState::new(contents.rows.len(), ListAlignment::Top, px(1.)) - .measure_all(), + .with_uniform_item_height(px(24.)), settings: CsvPreviewSettings::default(), last_parse_end_time: None, engine: TableDataEngine::default(), @@ -194,22 +200,54 @@ impl CsvPreviewView { pub(crate) fn editor_state(&self) -> &EditorState { &self.active_editor_state } - pub(crate) fn apply_sort(&mut self) { - self.performance_metrics.record("Sort", || { - self.engine.apply_sort(); - }); + pub(crate) fn apply_sort(&mut self, cx: &mut Context) { + self.apply_filter_sort(cx); } - /// Update ordered indices when ordering or content changes - pub(crate) fn apply_filter_sort(&mut self) { - self.performance_metrics.record("Filter&sort", || { - self.engine.calculate_d2d_mapping(); - }); + pub fn clear_filters(&mut self, col: types::AnyColumn, cx: &mut Context) { + self.engine.clear_filters_for_col(col); + self.apply_filter_sort(cx); + } - // Update list state with filtered row count - let visible_rows = self.engine.d2d_mapping().visible_row_count(); - self.list_state = - gpui::ListState::new(visible_rows, ListAlignment::Top, px(100.)).measure_all(); + pub fn toggle_filter( + &mut self, + col: types::AnyColumn, + value: Option, + cx: &mut Context, + ) { + if let Err(err) = self.engine.toggle_filter(col, value) { + log::error!("Failed to toggle filter: {err}"); + return; + } + self.apply_filter_sort(cx); + } + + /// Spawns a background task to recompute the display-to-data mapping after a filter or sort + /// change. Storing the task cancels any previous in-flight computation automatically. + pub(crate) fn apply_filter_sort(&mut self, cx: &mut Context) { + let contents = self.engine.contents.clone(); + let filter_stack = self.engine.filter_stack.clone(); + let sorting = self.engine.applied_sorting; + + self.filter_sort_task = Some(cx.spawn(async move |this, cx| { + let mapping = cx + .background_spawn(async move { + DisplayToDataMapping::compute(&contents, &filter_stack, sorting) + }) + .await; + + this.update(cx, |view, cx| { + view.engine.set_d2d_mapping(mapping); + let visible_rows = view.engine.d2d_mapping().visible_row_count(); + // Approximation of single csv table row height. Will be re-measured on scrolling. + // This cheap solution allow to render scrollbar with fraction of a cost compared to `.measure_all()` call + let approximate_height = px(24.); + view.list_state + .reset_with_uniform_height(visible_rows, approximate_height); + cx.notify(); + }) + .ok(); + })); } pub fn resolve_active_item_as_csv_editor( @@ -301,7 +339,7 @@ impl PerformanceMetrics { .map(|(name, (duration, time))| { let took = duration.as_secs_f32() * 1000.; let ago = time.elapsed().as_secs(); - format!("{name}: {took:.2}ms {ago}s ago") + format!("{name}: {took:.3}ms {ago}s ago") }) .collect::>() .join("\n") diff --git a/crates/csv_preview/src/parser.rs b/crates/csv_preview/src/parser.rs index efa3573d7aa..116c8912a38 100644 --- a/crates/csv_preview/src/parser.rs +++ b/crates/csv_preview/src/parser.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use crate::{ CsvPreviewView, types::TableLikeContent, @@ -23,6 +25,7 @@ impl CsvPreviewView { cx: &mut Context, ) { let editor = self.active_editor_state.editor.clone(); + self.is_parsing = true; self.parsing_task = Some(self.parse_csv_in_background(wait_for_debounce, editor, cx)); } @@ -80,11 +83,13 @@ impl CsvPreviewView { .insert("Parsing", (parse_duration, Instant::now())); log::debug!("Parsed {} rows", parsed_csv.rows.len()); - view.engine.contents = parsed_csv; + view.engine.contents = Arc::new(parsed_csv); + view.engine.calculate_available_filters(); view.sync_column_widths(cx); view.last_parse_end_time = Some(parse_end_time); - view.apply_filter_sort(); + view.is_parsing = false; + view.apply_filter_sort(cx); cx.notify(); }) }) diff --git a/crates/csv_preview/src/renderer/performance_metrics_overlay.rs b/crates/csv_preview/src/renderer/performance_metrics_overlay.rs index 3d0cf50cf1d..d9e7ce6ad9b 100644 --- a/crates/csv_preview/src/renderer/performance_metrics_overlay.rs +++ b/crates/csv_preview/src/renderer/performance_metrics_overlay.rs @@ -20,7 +20,7 @@ impl CsvPreviewView { let children = div() .absolute() - .top_24() + .bottom_8() .right_4() .px_3() .py_2() diff --git a/crates/csv_preview/src/renderer/preview_view.rs b/crates/csv_preview/src/renderer/preview_view.rs index 90500d53d06..335633656af 100644 --- a/crates/csv_preview/src/renderer/preview_view.rs +++ b/crates/csv_preview/src/renderer/preview_view.rs @@ -1,6 +1,6 @@ use std::time::Instant; -use ui::{div, prelude::*}; +use ui::{SpinnerLabel, div, prelude::*}; use crate::CsvPreviewView; @@ -11,12 +11,12 @@ impl Render for CsvPreviewView { let render_prep_start = Instant::now(); let table_with_settings = v_flex() .size_full() - .p_4() .bg(theme.colors().editor_background) .track_focus(&self.focus_handle) .child(self.render_settings_panel(window, cx)) .child({ - if self.engine.contents.number_of_cols == 0 { + let is_parsing = self.is_parsing; + if is_parsing || self.engine.contents.number_of_cols == 0 { div() .flex() .items_center() @@ -25,7 +25,15 @@ impl Render for CsvPreviewView { .text_ui(cx) .font_buffer(cx) .text_color(cx.theme().colors().text_muted) - .child("No CSV content to display") + .when(is_parsing, |div| { + div.child( + h_flex() + .gap_2() + .child(SpinnerLabel::new()) + .child("Loading…"), + ) + }) + .when(!is_parsing, |div| div.child("No CSV content to display")) .into_any_element() } else { self.create_table(&self.column_widths.widths, cx) diff --git a/crates/csv_preview/src/renderer/row_identifiers.rs b/crates/csv_preview/src/renderer/row_identifiers.rs index 06a26e4696e..e24441d3885 100644 --- a/crates/csv_preview/src/renderer/row_identifiers.rs +++ b/crates/csv_preview/src/renderer/row_identifiers.rs @@ -171,6 +171,7 @@ impl CsvPreviewView { .px_1() .border_b_1() .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().panel_background) .h_full() .text_ui(cx) // Row identifiers are always centered diff --git a/crates/csv_preview/src/renderer/settings.rs b/crates/csv_preview/src/renderer/settings.rs index cafa2a4c1bd..18c185bb0f3 100644 --- a/crates/csv_preview/src/renderer/settings.rs +++ b/crates/csv_preview/src/renderer/settings.rs @@ -3,7 +3,10 @@ use ui::{ IntoElement as _, ParentElement as _, Styled as _, Tooltip, Window, div, h_flex, }; -use crate::{CsvPreviewView, settings::VerticalAlignment}; +use crate::{ + CsvPreviewView, + settings::{FilterSortOrder, VerticalAlignment}, +}; ///// Settings related ///// impl CsvPreviewView { @@ -18,6 +21,11 @@ impl CsvPreviewView { VerticalAlignment::Center => "Center", }; + let current_filter_sort_text = match self.settings.filter_sort_order { + FilterSortOrder::AlphaThenCount => "A-Z, then Count", + FilterSortOrder::CountThenAlpha => "Count, then A-Z", + }; + let view = cx.entity(); let alignment_dropdown_menu = ContextMenu::build(window, cx, |menu, _window, _cx| { menu.entry("Top", None, { @@ -40,6 +48,27 @@ impl CsvPreviewView { }) }); + let filter_sort_dropdown_menu = ContextMenu::build(window, cx, |menu, _window, _cx| { + menu.entry("A-Z, then Count", None, { + let view = view.clone(); + move |_window, cx| { + view.update(cx, |this, cx| { + this.settings.filter_sort_order = FilterSortOrder::AlphaThenCount; + cx.notify(); + }); + } + }) + .entry("Count, then A-Z", None, { + let view = view.clone(); + move |_window, cx| { + view.update(cx, |this, cx| { + this.settings.filter_sort_order = FilterSortOrder::CountThenAlpha; + cx.notify(); + }); + } + }) + }); + let panel = h_flex() .gap_4() .p_2() @@ -68,6 +97,28 @@ impl CsvPreviewView { "Choose vertical text alignment within cells", )), ), + ) + .child( + h_flex() + .gap_2() + .items_center() + .child( + div() + .text_sm() + .text_color(cx.theme().colors().text_muted) + .child("Filter Sort:"), + ) + .child( + DropdownMenu::new( + ElementId::Name("filter-sort-order-dropdown".into()), + current_filter_sort_text, + filter_sort_dropdown_menu, + ) + .trigger_size(ButtonSize::Compact) + .trigger_tooltip(Tooltip::text( + "Choose how filter values are sorted in the filter menu", + )), + ), ); #[cfg(feature = "dev-tools")] diff --git a/crates/csv_preview/src/renderer/table_header.rs b/crates/csv_preview/src/renderer/table_header.rs index 05652b49a48..1ce8d34d22b 100644 --- a/crates/csv_preview/src/renderer/table_header.rs +++ b/crates/csv_preview/src/renderer/table_header.rs @@ -1,9 +1,13 @@ use gpui::ElementId; -use ui::{Tooltip, prelude::*}; +use ui::{ContextMenu, PopoverMenu, Tooltip, prelude::*}; use crate::{ CsvPreviewView, - table_data_engine::sorting_by_column::{AppliedSorting, SortDirection}, + settings::FilterSortOrder, + table_data_engine::{ + filtering_by_column::{FilterEntry, FilterEntryState}, + sorting_by_column::{AppliedSorting, SortDirection}, + }, types::AnyColumn, }; @@ -22,7 +26,12 @@ impl CsvPreviewView { .w_full() .font_buffer(cx) .child(div().child(header_text)) - .child(h_flex().gap_1().child(self.create_sort_button(cx, col_idx))) + .child( + h_flex() + .gap_1() + .child(self.create_filter_button(cx, col_idx)) + .child(self.create_sort_button(cx, col_idx)), + ) .into_any_element() } @@ -82,9 +91,191 @@ impl CsvPreviewView { }; this.engine.applied_sorting = new_sorting; - this.apply_sort(); + this.apply_sort(cx); cx.notify(); })); sort_btn } + + fn create_filter_button( + &self, + cx: &mut Context<'_, CsvPreviewView>, + col: AnyColumn, + ) -> PopoverMenu { + let has_active_filters = self.engine.has_active_filters(col); + + PopoverMenu::new(ElementId::NamedInteger( + "filter-menu".into(), + col.get() as u64, + )) + .trigger_with_tooltip( + Button::new( + ElementId::NamedInteger("filter-button".into(), col.get() as u64), + if has_active_filters { "⛊" } else { "⛉" }, + ) + .size(ButtonSize::Compact) + .style(if has_active_filters { + ButtonStyle::Filled + } else { + ButtonStyle::Subtle + }), + Tooltip::text(if has_active_filters { + "Column has active filters. Click to manage" + } else { + "No filters applied. Click to add filters" + }), + ) + .menu({ + let view_entity = cx.entity(); + move |window, cx| { + let view = view_entity.read(cx); + let column_filters = match view.engine.get_filters_for_column(col) { + Ok(filters) => filters, + Err(err) => { + log::error!("Failed to get filters for column: {err}"); + return None; + } + }; + let filter_sort_order = view.settings.filter_sort_order; + let filter_menu = Self::create_filter_menu( + window, + cx, + view_entity.clone(), + col, + &column_filters, + has_active_filters, + filter_sort_order, + ); + Some(filter_menu) + } + }) + } + + fn create_filter_menu( + window: &mut ui::Window, + cx: &mut ui::App, + view_entity: gpui::Entity, + col: AnyColumn, + column_filters: &[(FilterEntry, FilterEntryState)], + has_active_filters: bool, + sort_order: FilterSortOrder, + ) -> gpui::Entity { + let mut available: Vec<&FilterEntry> = column_filters + .iter() + .filter_map(|(entry, state)| { + matches!(state, FilterEntryState::Available { .. }).then_some(entry) + }) + .collect(); + + match sort_order { + FilterSortOrder::AlphaThenCount => available.sort_by(|a, b| { + a.content + .cmp(&b.content) + .then_with(|| b.occurred_times().cmp(&a.occurred_times())) + }), + FilterSortOrder::CountThenAlpha => available.sort_by(|a, b| { + b.occurred_times() + .cmp(&a.occurred_times()) + .then_with(|| a.content.cmp(&b.content)) + }), + } + + let unavailable: Vec<(&FilterEntry, AnyColumn)> = column_filters + .iter() + .filter_map(|(entry, state)| { + if let FilterEntryState::Unavailable { blocked_by } = state { + Some((entry, *blocked_by)) + } else { + None + } + }) + .collect(); + + // Pre-build applied-state lookup before moving into the closure + let applied_states: Vec<(FilterEntry, bool)> = column_filters + .iter() + .filter_map(|(entry, state)| { + if let FilterEntryState::Available { is_applied } = state { + Some((entry.clone(), *is_applied)) + } else { + None + } + }) + .collect(); + + let available_cloned: Vec = available.iter().map(|e| (*e).clone()).collect(); + let unavailable_cloned: Vec<(FilterEntry, AnyColumn)> = unavailable + .into_iter() + .map(|(e, col)| (e.clone(), col)) + .collect(); + + ContextMenu::build(window, cx, move |menu, _, _| { + let mut menu = menu; + + if has_active_filters { + menu = menu + .toggleable_entry("Clear all", false, ui::IconPosition::Start, None, { + let view_entity = view_entity.clone(); + move |_window, cx| { + view_entity.update(cx, |view, cx| { + view.clear_filters(col, cx); + cx.notify(); + }); + } + }) + .separator(); + } + + for entry in &available_cloned { + let is_applied = applied_states + .iter() + .find(|(e, _)| e.content == entry.content) + .map_or(false, |(_, applied)| *applied); + + let label: SharedString = + format_filter_label(entry.content.as_ref(), entry.occurred_times()).into(); + let entry_value = entry.content.clone(); + + menu = menu.toggleable_entry(&label, is_applied, ui::IconPosition::Start, None, { + let view_entity = view_entity.clone(); + move |_window, cx| { + view_entity.update(cx, |view, cx| { + view.toggle_filter(col, entry_value.clone(), cx); + cx.notify(); + }); + } + }); + } + + if !unavailable_cloned.is_empty() { + menu = menu.separator().header("Hidden by other filters"); + for (entry, _blocked_by) in &unavailable_cloned { + let label: SharedString = + format_filter_label(entry.content.as_ref(), entry.occurred_times()).into(); + menu = menu.custom_entry( + { + let label = label.clone(); + move |_window, cx| { + div() + .px_2() + .text_color(cx.theme().colors().text_muted) + .child(label.clone()) + .into_any_element() + } + }, + |_, _| {}, + ); + } + } + + menu + }) + } +} + +fn format_filter_label(content: Option<&SharedString>, count: usize) -> String { + match content { + Some(s) => format!("{s} ({count})"), + None => format!(" ({count})"), + } } diff --git a/crates/csv_preview/src/settings.rs b/crates/csv_preview/src/settings.rs index 215d681c28f..2d5cc78c5f5 100644 --- a/crates/csv_preview/src/settings.rs +++ b/crates/csv_preview/src/settings.rs @@ -1,10 +1,10 @@ #[derive(Default, Clone, Copy, PartialEq)] pub enum RowRenderMechanism { /// More correct for multiline content, but slower. - #[allow(dead_code)] // Will be used when settings ui is added + #[default] VariableList, /// Default behaviour for now while resizable columns are being stabilized. - #[default] + #[allow(dead_code)] // Will be used when settings ui is added UniformList, } @@ -26,11 +26,21 @@ pub enum RowIdentifiers { RowNum, } +#[derive(Default, Clone, Copy, PartialEq)] +pub enum FilterSortOrder { + /// Sort alphabetically (A→Z), then by number of occurrences descending within ties + #[default] + AlphaThenCount, + /// Sort by number of occurrences descending, then alphabetically within ties + CountThenAlpha, +} + #[derive(Clone, Default)] pub(crate) struct CsvPreviewSettings { pub(crate) rendering_with: RowRenderMechanism, pub(crate) vertical_alignment: VerticalAlignment, pub(crate) numbering_type: RowIdentifiers, + pub(crate) filter_sort_order: FilterSortOrder, pub(crate) show_debug_info: bool, #[cfg(feature = "dev-tools")] pub(crate) show_perf_metrics_overlay: bool, diff --git a/crates/csv_preview/src/table_data_engine.rs b/crates/csv_preview/src/table_data_engine.rs index 382b41a2850..f2613215fa5 100644 --- a/crates/csv_preview/src/table_data_engine.rs +++ b/crates/csv_preview/src/table_data_engine.rs @@ -5,22 +5,32 @@ //! //! It's designed to contain core logic of operations without relying on `CsvPreviewView`, context or window handles. -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; use ui::table_row::TableRow; use crate::{ - table_data_engine::sorting_by_column::{AppliedSorting, sort_data_rows}, - types::{DataRow, DisplayRow, TableCell, TableLikeContent}, + table_data_engine::{ + filtering_by_column::{FilterEntry, FilterStack, calculate_available_filters, retain_rows}, + sorting_by_column::{AppliedSorting, sort_data_rows}, + }, + types::{AnyColumn, DataRow, DisplayRow, TableCell, TableLikeContent}, }; +pub mod filtering_by_column; pub mod sorting_by_column; #[derive(Default)] pub(crate) struct TableDataEngine { + pub filter_stack: FilterStack, + /// Pre-computed unique values per column, used to populate filter menus + all_filters: HashMap>, pub applied_sorting: Option, d2d_mapping: DisplayToDataMapping, - pub contents: TableLikeContent, + pub contents: Arc, } impl TableDataEngine { @@ -28,32 +38,47 @@ impl TableDataEngine { &self.d2d_mapping } - pub(crate) fn apply_sort(&mut self) { - self.d2d_mapping - .apply_sorting(self.applied_sorting, &self.contents.rows); - self.d2d_mapping.merge_mappings(); + pub(crate) fn set_d2d_mapping(&mut self, mapping: DisplayToDataMapping) { + self.d2d_mapping = mapping; } - /// Applies sorting and filtering to the data and produces display to data mapping - pub(crate) fn calculate_d2d_mapping(&mut self) { - self.d2d_mapping - .apply_sorting(self.applied_sorting, &self.contents.rows); - self.d2d_mapping.merge_mappings(); + /// Recomputes the unique filter entries for every column from the current table data. + /// Must be called after content changes (e.g. after parsing). + pub fn calculate_available_filters(&mut self) { + self.all_filters = + calculate_available_filters(&self.contents.rows, self.contents.number_of_cols); } } /// Relation of Display (rendered) rows to Data (src) rows with applied transformations /// Transformations applied: /// - sorting by column +/// - filtering by column values #[derive(Debug, Default)] pub struct DisplayToDataMapping { - /// All rows sorted, regardless of applied filtering. Applied every time sorting changes + /// All rows sorted, regardless of applied filtering. Recomputed every time sorting changes pub sorted_rows: Vec, - /// Filtered and sorted rows. Computed cheaply from `sorted_mapping` and `filtered_out_rows` + /// Rows that survive the active filters. Recomputed every time filters change + pub retained_rows: HashSet, + /// Merged result: sorted rows intersected with retained rows pub mapping: Arc>, } impl DisplayToDataMapping { + /// Computes the full display-to-data mapping from owned inputs. + /// Intended to be called from a background thread. + pub(crate) fn compute( + contents: &Arc, + filter_stack: &FilterStack, + sorting: Option, + ) -> Self { + let mut mapping = Self::default(); + mapping.apply_sorting(sorting, &contents.rows); + mapping.apply_filtering(filter_stack, &contents.rows); + mapping.merge_mappings(); + mapping + } + /// Get the data row for a given display row pub fn get_data_row(&self, display_row: DisplayRow) -> Option { self.mapping.get(&display_row).copied() @@ -77,11 +102,16 @@ impl DisplayToDataMapping { self.sorted_rows = sorted_rows; } - /// Take pre-computed sorting and filtering results, and apply them to the mapping + fn apply_filtering(&mut self, filter_stack: &FilterStack, rows: &[TableRow]) { + self.retained_rows = retain_rows(rows, filter_stack); + } + + /// Merges pre-computed sorting and filtering into the final display mapping fn merge_mappings(&mut self) { self.mapping = Arc::new( self.sorted_rows .iter() + .filter(|data_row| self.retained_rows.contains(data_row)) .enumerate() .map(|(display, data)| (DisplayRow(display), *data)) .collect(), diff --git a/crates/csv_preview/src/table_data_engine/filtering_by_column.rs b/crates/csv_preview/src/table_data_engine/filtering_by_column.rs new file mode 100644 index 00000000000..b2b8a78c05a --- /dev/null +++ b/crates/csv_preview/src/table_data_engine/filtering_by_column.rs @@ -0,0 +1,250 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; + +use ui::{SharedString, table_row::TableRow}; + +use crate::{ + table_data_engine::TableDataEngine, + types::{AnyColumn, DataRow, TableCell}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum FilterEntryState { + Available { is_applied: bool }, + Unavailable { blocked_by: AnyColumn }, +} + +#[derive(Debug, Clone)] +pub struct FilterEntry { + /// Content to display. None if cell is virtual + pub content: Option, + /// List of rows in which this value occurs + pub rows: Vec, +} + +impl FilterEntry { + pub(crate) fn occurred_times(&self) -> usize { + self.rows.len() + } +} + +#[derive(Debug, Default, Clone)] +pub(crate) struct FilterStack { + /// Columns in the order their first filter was applied, used to compute cascade availability + activation_order: Vec, + /// Which cell values are currently allowed for each filtered column + retention_config: HashMap>>, +} + +impl TableDataEngine { + pub(crate) fn has_active_filters(&self, col: AnyColumn) -> bool { + self.filter_stack.retention_config.contains_key(&col) + } + + /// Get available filters for a specific column with cascade behavior. + /// + /// A filter entry is "unavailable" when all of its rows are hidden by a + /// filter on an earlier-activated column, meaning selecting it would show + /// zero rows. The cascade walk stops at `column` so that the column's own + /// current filter does not affect its own entry availability. + pub(crate) fn get_filters_for_column( + &self, + column: AnyColumn, + ) -> anyhow::Result>> { + let all_column_entries = self + .all_filters + .get(&column) + .ok_or_else(|| anyhow::anyhow!("Expected {column:?} to have filter entries"))?; + + let mut unavailable_entries: HashMap, AnyColumn> = HashMap::new(); + + for &column_applied_previously in &self.filter_stack.activation_order { + if column_applied_previously == column { + break; + } + + let retained_values = self + .filter_stack + .retention_config + .get(&column_applied_previously) + .ok_or_else(|| { + anyhow::anyhow!( + "Expected {column_applied_previously:?} to have retained entries \ + as it is present in the filter stack" + ) + })?; + + // Rows that survive the filter on `column_applied_previously` + let retained_rows: HashSet = self + .contents + .rows + .iter() + .enumerate() + .filter(|(_, row)| { + let cell_value = row + .get(column_applied_previously) + .and_then(|cell| cell.display_value().cloned()); + retained_values.contains(&cell_value) + }) + .map(|(index, _)| DataRow(index)) + .collect(); + + // An entry is unavailable when none of its rows survive the parent filter + for entry in all_column_entries { + if !entry.rows.iter().any(|row| retained_rows.contains(row)) { + unavailable_entries.insert(entry.content.clone(), column_applied_previously); + } + } + } + + let empty = HashSet::new(); + let active_column_filters = self + .filter_stack + .retention_config + .get(&column) + .unwrap_or(&empty); + + Ok(Arc::new( + all_column_entries + .iter() + .map(|entry| { + let state = if let Some(&blocked_by) = unavailable_entries.get(&entry.content) { + FilterEntryState::Unavailable { blocked_by } + } else { + FilterEntryState::Available { + is_applied: active_column_filters.contains(&entry.content), + } + }; + (entry.clone(), state) + }) + .collect(), + )) + } + + pub(crate) fn clear_filters_for_col(&mut self, col: AnyColumn) { + self.filter_stack + .activation_order + .retain(|&entry| entry != col); + self.filter_stack.retention_config.remove(&col); + } + + /// Toggle a filter value for a column. Returns `true` if the filter was + /// added, `false` if it was removed. + pub(crate) fn toggle_filter( + &mut self, + column: AnyColumn, + value: Option, + ) -> anyhow::Result { + let is_currently_applied = self + .filter_stack + .retention_config + .get(&column) + .is_some_and(|filters| filters.contains(&value)); + + if is_currently_applied { + self.remove_filter(column, value)?; + Ok(false) + } else { + self.apply_filter(column, value); + Ok(true) + } + } + + fn remove_filter( + &mut self, + column: AnyColumn, + value: Option, + ) -> anyhow::Result<()> { + let entries = self + .filter_stack + .retention_config + .get_mut(&column) + .ok_or_else(|| { + anyhow::anyhow!("Expected {column:?} to be present in active filters") + })?; + + debug_assert!( + entries.contains(&value), + "Expected value to be present in {column:?} active filters" + ); + + if entries.len() == 1 { + self.filter_stack.retention_config.remove(&column); + self.filter_stack + .activation_order + .retain(|&entry| entry != column); + } else { + entries.remove(&value); + } + Ok(()) + } + + fn apply_filter(&mut self, column: AnyColumn, value: Option) { + // Track the column only on its first activation to preserve cascade order + if !self.filter_stack.activation_order.contains(&column) { + self.filter_stack.activation_order.push(column); + } + self.filter_stack + .retention_config + .entry(column) + .or_default() + .insert(value); + } +} + +/// Calculate available filter entries for each column from the table data. +pub fn calculate_available_filters( + content_rows: &[TableRow], + number_of_cols: usize, +) -> HashMap> { + let mut available_filters = HashMap::new(); + + for col_idx in 0..number_of_cols { + let column = AnyColumn::new(col_idx); + let mut value_to_rows: HashMap, Vec> = HashMap::new(); + + for (row_index, row) in content_rows.iter().enumerate() { + let cell_value = row + .get(column) + .and_then(|cell| cell.display_value().cloned()); + value_to_rows + .entry(cell_value) + .or_default() + .push(DataRow(row_index)); + } + + let filter_entries: Vec = value_to_rows + .into_iter() + .map(|(content, rows)| FilterEntry { content, rows }) + .collect(); + + available_filters.insert(column, filter_entries); + } + + available_filters +} + +/// Returns the set of data rows that survive all active filters in the stack. +pub fn retain_rows( + content_rows: &[TableRow], + filter_stack: &FilterStack, +) -> HashSet { + let config = &filter_stack.retention_config; + if config.is_empty() { + return (0..content_rows.len()).map(DataRow).collect(); + } + + content_rows + .iter() + .enumerate() + .filter(|(_, row)| { + config.iter().all(|(col, allowed_values)| { + let cell_value = row.get(*col).and_then(|cell| cell.display_value().cloned()); + allowed_values.contains(&cell_value) + }) + }) + .map(|(index, _)| DataRow(index)) + .collect() +} diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 85b38d5234e..62b481bc530 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -338,6 +338,17 @@ impl ListState { self } + /// Pre-populate every unmeasured item with a uniform height hint so the scrollbar thumb + /// is correctly sized from the first frame, without measuring all items up front. + /// + /// As items are actually rendered their real heights replace the hint, so the scrollbar + /// converges to the exact size over time. This is a cheaper alternative to [`Self::measure_all`] + /// for lists where items have roughly uniform heights (e.g. table rows). + pub fn with_uniform_item_height(self, height: Pixels) -> Self { + self.apply_uniform_item_height(height); + self + } + /// Reset this instantiation of the list state. /// /// Note that this will cause scroll events to be dropped until the next paint. @@ -355,6 +366,33 @@ impl ListState { self.splice(0..old_count, element_count); } + /// Reset the list to `element_count` items, pre-populating every item with a + /// uniform height hint so the scrollbar thumb is correctly sized from the first + /// frame even for off-screen items. + pub fn reset_with_uniform_height(&self, element_count: usize, height: Pixels) { + self.reset(element_count); + self.apply_uniform_item_height(height); + } + + fn apply_uniform_item_height(&self, height: Pixels) { + let size_hint = Size { + width: px(0.), + height, + }; + let mut state = self.0.borrow_mut(); + let new_items = state + .items + .iter() + .map(|item| ListItem::Unmeasured { + size_hint: Some(item.size_hint().unwrap_or(size_hint)), + focus_handle: item.focus_handle(), + }) + .collect::>(); + let mut tree = SumTree::default(); + tree.extend(new_items, ()); + state.items = tree; + } + /// Remeasure all items while preserving proportional scroll position. /// /// Use this when item heights may have changed (e.g., font size changes) From 6979d7281f261004e72c15a14f7c31da89d16e63 Mon Sep 17 00:00:00 2001 From: "zed-zippy[bot]" <234243425+zed-zippy[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:06:44 +0200 Subject: [PATCH 148/197] Bump Zed to v1.12.0 (#60600) Release Notes: - N/A Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1d5fe8fd9bd..b9b4ea96158 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22994,7 +22994,7 @@ dependencies = [ [[package]] name = "zed" -version = "1.11.0" +version = "1.12.0" dependencies = [ "acp_thread", "acp_tools", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 2b67736bf0e..213147207c3 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "1.11.0" +version = "1.12.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From 7f5cf583dc72035efe68cb3ef086857cda0c7a47 Mon Sep 17 00:00:00 2001 From: Jiby Jose Date: Wed, 8 Jul 2026 18:37:02 +0200 Subject: [PATCH 149/197] Fix worktree entry IDs for symlinked files (#57846) 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 #55792 Release Notes: - Fixed files in pnpm workspaces moving to symlinked `node_modules` paths after saving. --------- Co-authored-by: Kirill Bulatov --- crates/worktree/src/worktree.rs | 122 ++++++++++++------ .../tests/integration/worktree_tests.rs | 92 +++++++++++++ 2 files changed, 174 insertions(+), 40 deletions(-) diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index c31385f4553..6c90de189b5 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -272,16 +272,65 @@ struct BackgroundScannerState { watched_dir_abs_paths_by_entry_id: HashMap>, path_prefixes_to_scan: HashSet>, paths_to_scan: HashSet>, - /// The ids of all of the entries that were removed from the snapshot - /// as part of the current update. These entry ids may be re-used - /// if the same inode is discovered at a new path, or if the given - /// path is re-created after being deleted. - removed_entries: HashMap, + removed_entries: RemovedEntries, changed_paths: Vec>, prev_snapshot: Snapshot, scanning_enabled: bool, } +/// The entries that were removed from the snapshot as part of the current +/// update. Their entry ids may be re-used if the same inode is discovered +/// at a new path, or if the given path is re-created after being deleted. +/// +/// Symlink aliases inside the worktree share their inode (and usually mtime) +/// with the symlink target, so an inode may correspond to several entries. +/// The path index allows an exact match to take precedence over the +/// inode-based rename heuristics in that case. +#[derive(Default)] +struct RemovedEntries { + by_inode: HashMap, + by_path: HashMap, Entry>, +} + +impl RemovedEntries { + fn insert(&mut self, entry: &Entry) { + self.by_path.insert(entry.path.clone(), entry.clone()); + match self.by_inode.entry(entry.inode) { + hash_map::Entry::Occupied(mut o) => { + if entry.id > o.get().id { + o.insert(entry.clone()); + } + } + hash_map::Entry::Vacant(v) => { + v.insert(entry.clone()); + } + } + } + + fn take_by_path(&mut self, path: &RelPath, inode: u64) -> Option { + if self.by_path.get(path)?.inode != inode { + return None; + } + let removed = self.by_path.remove(path)?; + if let hash_map::Entry::Occupied(o) = self.by_inode.entry(removed.inode) + && o.get().id == removed.id + { + o.remove(); + } + Some(removed) + } + + fn take_by_inode(&mut self, inode: u64) -> Option { + let removed = self.by_inode.remove(&inode)?; + if let hash_map::Entry::Occupied(o) = self.by_path.entry(removed.path.clone()) + && o.get().id == removed.id + { + o.remove(); + } + Some(removed) + } +} + #[derive(Clone, Debug, Eq, PartialEq)] struct EventRoot { path: Arc, @@ -1229,7 +1278,7 @@ impl LocalWorktree { scanning_enabled, path_prefixes_to_scan: Default::default(), paths_to_scan: Default::default(), - removed_entries: Default::default(), + removed_entries: RemovedEntries::default(), changed_paths: Default::default(), }), phase: BackgroundScannerPhase::InitialScan, @@ -3086,21 +3135,11 @@ impl BackgroundScannerState { } fn reuse_entry_id(&mut self, entry: &mut Entry) { - if let Some(mtime) = entry.mtime { - // If an entry with the same inode was removed from the worktree during this scan, - // then it *might* represent the same file or directory. But the OS might also have - // re-used the inode for a completely different file or directory. - // - // Conditionally reuse the old entry's id: - // * if the mtime is the same, the file was probably been renamed. - // * if the path is the same, the file may just have been updated - if let Some(removed_entry) = self.removed_entries.remove(&entry.inode) { - if removed_entry.mtime == Some(mtime) || removed_entry.path == entry.path { - entry.id = removed_entry.id; - } - } else if let Some(existing_entry) = self.snapshot.entry_for_path(&entry.path) { - entry.id = existing_entry.id; - } + let Some(mtime) = entry.mtime else { + return; + }; + if let Some(entry_id) = self.reused_entry_id(&entry.path, entry.inode, mtime) { + entry.id = entry_id; } } @@ -3110,6 +3149,20 @@ impl BackgroundScannerState { path: &RelPath, metadata: &fs::Metadata, ) -> ProjectEntryId { + self.reused_entry_id(path, metadata.inode, metadata.mtime) + .unwrap_or_else(|| ProjectEntryId::new(next_entry_id)) + } + + fn reused_entry_id( + &mut self, + path: &RelPath, + inode: u64, + mtime: MTime, + ) -> Option { + if let Some(removed_entry) = self.removed_entries.take_by_path(path, inode) { + return Some(removed_entry.id); + } + // If an entry with the same inode was removed from the worktree during this scan, // then it *might* represent the same file or directory. But the OS might also have // re-used the inode for a completely different file or directory. @@ -3117,14 +3170,12 @@ impl BackgroundScannerState { // Conditionally reuse the old entry's id: // * if the mtime is the same, the file was probably been renamed. // * if the path is the same, the file may just have been updated - if let Some(removed_entry) = self.removed_entries.remove(&metadata.inode) { - if removed_entry.mtime == Some(metadata.mtime) || *removed_entry.path == *path { - return removed_entry.id; - } - } else if let Some(existing_entry) = self.snapshot.entry_for_path(path) { - return existing_entry.id; + if let Some(removed_entry) = self.removed_entries.take_by_inode(inode) { + (removed_entry.mtime == Some(mtime) || *removed_entry.path == *path) + .then_some(removed_entry.id) + } else { + Some(self.snapshot.entry_for_path(path)?.id) } - ProjectEntryId::new(next_entry_id) } async fn insert_entry(&mut self, entry: Entry, fs: &dyn Fs, watcher: &dyn Watcher) -> Entry { @@ -3292,17 +3343,7 @@ impl BackgroundScannerState { removed_dir_abs_paths.push(watch_path); } - match self.removed_entries.entry(entry.inode) { - hash_map::Entry::Occupied(mut e) => { - let prev_removed_entry = e.get_mut(); - if entry.id > prev_removed_entry.id { - *prev_removed_entry = entry.clone(); - } - } - hash_map::Entry::Vacant(e) => { - e.insert(entry.clone()); - } - } + self.removed_entries.insert(entry); if entry.path.file_name() == Some(GITIGNORE) { let abs_parent_path = self.snapshot.absolutize(&entry.path.parent().unwrap()); @@ -4800,7 +4841,8 @@ impl BackgroundScanner { { let mut state = self.state.lock().await; state.snapshot.completed_scan_id = state.snapshot.scan_id; - for (_, entry) in mem::take(&mut state.removed_entries) { + let RemovedEntries { by_inode, by_path } = mem::take(&mut state.removed_entries); + for entry in by_inode.into_values().chain(by_path.into_values()) { state.scanned_dirs.remove(&entry.id); } } diff --git a/crates/worktree/tests/integration/worktree_tests.rs b/crates/worktree/tests/integration/worktree_tests.rs index 1fd7ed5b557..f63bf7d1085 100644 --- a/crates/worktree/tests/integration/worktree_tests.rs +++ b/crates/worktree/tests/integration/worktree_tests.rs @@ -1198,6 +1198,98 @@ async fn test_real_fs_scan_symlinks_expanded(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_internal_symlink_updates_preserve_entry_ids(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + + fs.insert_tree( + "/root", + json!({ + "project": { + "real-dir": { + "existing.rs": "old", + }, + "links": {} + } + }), + ) + .await; + + fs.create_symlink( + "/root/project/links/internal".as_ref(), + "../real-dir".into(), + ) + .await + .unwrap(); + + let tree = Worktree::local( + Path::new("/root/project"), + true, + fs.clone(), + Default::default(), + true, + WorktreeId::from_proto(0), + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + let (real_entry_id, symlink_entry_id, old_mtime) = tree.read_with(cx, |tree, _| { + let real_entry = tree + .entry_for_path(rel_path("real-dir/existing.rs")) + .unwrap(); + let symlink_entry = tree + .entry_for_path(rel_path("links/internal/existing.rs")) + .unwrap(); + assert_eq!(real_entry.inode, symlink_entry.inode); + assert_eq!(real_entry.mtime, symlink_entry.mtime); + assert_ne!(real_entry.id, symlink_entry.id); + (real_entry.id, symlink_entry.id, real_entry.mtime) + }); + + fs.write(Path::new("/root/project/real-dir/existing.rs"), b"new") + .await + .unwrap(); + + wait_for_condition(cx, |cx| { + tree.read_with(cx, |tree, _| { + let real_entry = tree + .entry_for_path(rel_path("real-dir/existing.rs")) + .unwrap(); + let symlink_entry = tree + .entry_for_path(rel_path("links/internal/existing.rs")) + .unwrap(); + real_entry.mtime != old_mtime && symlink_entry.mtime != old_mtime + }) + }) + .await; + + tree.read_with(cx, |tree, _| { + let real_entry = tree + .entry_for_path(rel_path("real-dir/existing.rs")) + .unwrap(); + let symlink_entry = tree + .entry_for_path(rel_path("links/internal/existing.rs")) + .unwrap(); + + assert_eq!(real_entry.inode, symlink_entry.inode); + assert_eq!(real_entry.id, real_entry_id); + assert_eq!( + tree.entry_for_id(real_entry_id).unwrap().path.as_ref(), + rel_path("real-dir/existing.rs") + ); + assert_eq!(symlink_entry.id, symlink_entry_id); + assert_eq!( + tree.entry_for_id(symlink_entry_id).unwrap().path.as_ref(), + rel_path("links/internal/existing.rs") + ); + }); +} + #[cfg(target_os = "macos")] #[gpui::test] async fn test_renaming_case_only(cx: &mut TestAppContext) { From 029bf2f284b4e59f20175d78443e630468f3a3e5 Mon Sep 17 00:00:00 2001 From: TwoClocks <5883156+TwoClocks@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:37:08 -0700 Subject: [PATCH 150/197] Make editor::LineUp & editor::LineDown honor vertical_scroll_margin like vim::LineUp & vim::LineDown (#52057) ## Context This is an implementation of this feature: https://github.com/zed-industries/zed/discussions/49821 Although, I'd argue it's a bug fix, not a feature. Either way : This copies the window scroll logic from the `vim` mode versions with out all the extra logic for visual mode. Could refactor the common logic out of the vim code and make it common. But that seems like a bigger PR. Happy to take a stab at it if that's what you prefer. Could also add new commands for the new behavior if you prefer. I didn't do that, because it seems like more clutter in the commands, and my belief that the existing behavior is a bug. But happy to do that if you prefer. ## How to Review creates new function `scroll_screen_with_cursor_margin` in `scroll.rs` wires up editor::LineUp/LineDown to new function in `elements.rs` adds a test in `editor_tests.rs` ## 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: - editor::LineUp/LineDown commands now honor vertical_scroll_margin (same as vim::LineUp/LineDown) --------- Co-authored-by: Kirill Bulatov --- crates/editor/src/editor_tests.rs | 84 +++++++++++++++++++++++++++++++ crates/editor/src/element.rs | 12 ++--- crates/editor/src/scroll.rs | 59 +++++++++++++++++++++- 3 files changed, 148 insertions(+), 7 deletions(-) diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 2fa319300e5..ca5cc9a6a2f 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -2958,6 +2958,90 @@ async fn test_scroll_page_up_page_down(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_scroll_line_up_down_cursor_margin(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + let line_height = cx.update_editor(|editor, window, cx| { + editor.set_vertical_scroll_margin(2, cx); + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + let window = cx.window; + // 5 visible lines with margin=2: valid cursor rows are [top+2 .. top+2] (only the middle row) + cx.simulate_window_resize(window, size(px(1000.), 5. * line_height)); + + // Cursor at row 0 — autoscroll leaves viewport at y=0. + cx.set_state(indoc! {" + ˇone + two + three + four + five + six + seven + eight + nine + ten + "}); + + cx.update_editor(|editor, window, cx| { + assert_eq!(editor.snapshot(window, cx).scroll_position().y, 0.); + + // Scroll down 1: top=1, visible rows 1-5, margin=2 → min_row=3, max_row=3. + // Cursor at row 0 < min_row=3 → clamped to row 3. + editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(1.), window, cx); + assert_eq!(editor.snapshot(window, cx).scroll_position().y, 1.); + let snapshot = editor.display_snapshot(cx); + assert_eq!( + editor.selections.newest_display(&snapshot).head().row().0, + 3, + "cursor clamped to min_row after scrolling down" + ); + }); + + cx.update_editor(|editor, window, cx| { + // Scroll up 1: top=0, visible rows 0-4, margin=2 (top==0 special case) → min_row=0, max_row=2. + // Cursor at row 3 > max_row=2 → clamped to row 2. + editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(-1.), window, cx); + assert_eq!(editor.snapshot(window, cx).scroll_position().y, 0.); + let snapshot = editor.display_snapshot(cx); + assert_eq!( + editor.selections.newest_display(&snapshot).head().row().0, + 2, + "cursor clamped to max_row after scrolling up" + ); + }); + + cx.update_editor(|editor, window, cx| { + // Half page down (2 lines): top=2, visible rows 2-6, margin=2 → min_row=4, max_row=4. + // Cursor at row 2 < min_row=4 → clamped to row 4. + editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(0.5), window, cx); + assert_eq!(editor.snapshot(window, cx).scroll_position().y, 2.); + let snapshot = editor.display_snapshot(cx); + assert_eq!( + editor.selections.newest_display(&snapshot).head().row().0, + 4, + "cursor clamped to min_row after scrolling half page down" + ); + }); + + cx.update_editor(|editor, window, cx| { + // Half page up (2 lines): top=0, visible rows 0-4, margin=2 (top==0 special case) → min_row=0, max_row=2. + // Cursor at row 4 > max_row=2 → clamped to row 2. + editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(-0.5), window, cx); + assert_eq!(editor.snapshot(window, cx).scroll_position().y, 0.); + let snapshot = editor.display_snapshot(cx); + assert_eq!( + editor.selections.newest_display(&snapshot).head().row().0, + 2, + "cursor clamped to max_row after scrolling half page up" + ); + }); +} + #[gpui::test] async fn test_autoscroll(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index a82b3ea4488..4ce36632f25 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -287,22 +287,22 @@ impl EditorElement { register_action(editor, window, Editor::scroll_cursor_bottom); register_action(editor, window, Editor::scroll_cursor_center_top_bottom); register_action(editor, window, |editor, _: &LineDown, window, cx| { - editor.scroll_screen(&ScrollAmount::Line(1.), window, cx) + editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(1.), window, cx) }); register_action(editor, window, |editor, _: &LineUp, window, cx| { - editor.scroll_screen(&ScrollAmount::Line(-1.), window, cx) + editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(-1.), window, cx) }); register_action(editor, window, |editor, _: &HalfPageDown, window, cx| { - editor.scroll_screen(&ScrollAmount::Page(0.5), window, cx) + editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(0.5), window, cx) }); register_action(editor, window, |editor, _: &HalfPageUp, window, cx| { - editor.scroll_screen(&ScrollAmount::Page(-0.5), window, cx) + editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(-0.5), window, cx) }); register_action(editor, window, |editor, _: &PageDown, window, cx| { - editor.scroll_screen(&ScrollAmount::Page(1.), window, cx) + editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(1.), window, cx) }); register_action(editor, window, |editor, _: &PageUp, window, cx| { - editor.scroll_screen(&ScrollAmount::Page(-1.), window, cx) + editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(-1.), window, cx) }); register_action(editor, window, Editor::move_to_previous_word_start); register_action(editor, window, Editor::move_to_previous_subword_start); diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs index ec7f9036c4a..dcd96c675c5 100644 --- a/crates/editor/src/scroll.rs +++ b/crates/editor/src/scroll.rs @@ -5,7 +5,7 @@ pub(crate) mod scroll_amount; use crate::editor_settings::ScrollBeyondLastLine; use crate::{ Anchor, DisplayPoint, DisplayRow, Editor, EditorEvent, EditorMode, EditorSettings, - MultiBufferSnapshot, RowExt, SizingBehavior, ToPoint, + MultiBufferSnapshot, RowExt, SelectionEffects, SizingBehavior, ToPoint, display_map::{DisplaySnapshot, ToDisplayPoint}, hover_popover::hide_hover, persistence::EditorDb, @@ -945,6 +945,63 @@ impl Editor { self.set_scroll_position(new_position, window, cx); } + pub fn scroll_screen_with_cursor_margin( + &mut self, + amount: &ScrollAmount, + window: &mut Window, + cx: &mut Context, + ) { + self.scroll_screen(amount, window, cx); + + let Some(visible_line_count) = self.visible_line_count() else { + return; + }; + let display_snapshot = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let top = self + .scroll_manager + .scroll_top_display_point(&display_snapshot, cx); + let vertical_scroll_margin = + (self.vertical_scroll_margin() as u32).min(visible_line_count as u32 / 2); + + let max_point = display_snapshot.max_point(); + let min_row = if top.row().0 == 0 { + DisplayRow(0) + } else { + DisplayRow(top.row().0 + vertical_scroll_margin) + }; + let max_row = if top.row().0 + visible_line_count as u32 >= max_point.row().0 { + max_point.row() + } else { + DisplayRow( + (top.row().0 + visible_line_count as u32) + .saturating_sub(1 + vertical_scroll_margin), + ) + }; + + self.change_selections( + SelectionEffects::no_scroll().nav_history(false), + window, + cx, + |s| { + s.move_with(&mut |map, selection| { + let head = selection.head(); + let new_row = if head.row() < min_row { + min_row + } else if head.row() > max_row { + max_row + } else { + head.row() + }; + if new_row != head.row() { + let new_head = + map.clip_point(DisplayPoint::new(new_row, head.column()), Bias::Left); + selection.collapse_to(new_head, selection.goal); + } + }) + }, + ); + } + /// Returns an ordering. The newest selection is: /// Ordering::Equal => on screen /// Ordering::Less => above or to the left of the screen From 7dc634124c249c5625da643d94f9fccefad80c56 Mon Sep 17 00:00:00 2001 From: Lena <241371603+zelenenka@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:52:33 +0200 Subject: [PATCH 151/197] Switch Guild board automation to role checks (#60606) Outside collaborators can't be added to GitHub teams, team membership is restricted to org members. https://docs.github.com/en/organizations/managing-user-access-to-your-organizations-repositories/managing-outside-collaborators/adding-outside-collaborators-to-repositories-in-your-organization#:~:text=Outside%20collaborators%20cannot%20be%20added%20to%20a%20team%2C%20team%20membership%20is%20restricted%20to%20members%20of%20the%20organization Release Notes: - N/A --- .github/workflows/guild_new_pr_notify.yml | 15 +++++++++----- .github/workflows/pr_issue_labeler.yml | 24 +++++++++++++++++++++-- script/github-guild-board.py | 15 ++++++++++---- 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/.github/workflows/guild_new_pr_notify.yml b/.github/workflows/guild_new_pr_notify.yml index babab1c8b9c..8aad061e5f8 100644 --- a/.github/workflows/guild_new_pr_notify.yml +++ b/.github/workflows/guild_new_pr_notify.yml @@ -40,18 +40,23 @@ jobs: with: github-token: ${{ steps.app-token.outputs.token }} script: | - const GUILD_TEAM_SLUG = 'guild-cohort-2'; + // Guild cohort members are outside collaborators holding this custom + // repository role, not members of an org team. + const GUILD_ROLE_NAME = 'Guild Assign issues/PRs'; const pr = context.payload.pull_request; const author = pr.user.login; const isGuildMember = async (username) => { try { - const response = await github.rest.teams.getMembershipForUserInOrg({ - org: 'zed-industries', - team_slug: GUILD_TEAM_SLUG, + const response = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: 'zed-industries', + repo: 'zed', username }); - return response.data.state === 'active'; + // role_name is the effective (highest) role; for cohort outside + // collaborators that is the custom role. Built-in roles come back + // lowercased and won't match. + return (response.data.role_name || '').toLowerCase() === GUILD_ROLE_NAME.toLowerCase(); } catch (error) { if (error.status === 404) { return false; diff --git a/.github/workflows/pr_issue_labeler.yml b/.github/workflows/pr_issue_labeler.yml index f295dcb4f9a..524d301aecc 100644 --- a/.github/workflows/pr_issue_labeler.yml +++ b/.github/workflows/pr_issue_labeler.yml @@ -41,7 +41,9 @@ jobs: const STAFF_TEAM_SLUG = 'staff'; const FIRST_CONTRIBUTION_LABEL = 'first contribution'; const GUILD_LABEL = 'guild'; - const GUILD_TEAM_SLUG = 'guild-cohort-2'; + // Guild cohort members are outside collaborators holding this custom + // repository role, not members of an org team. + const GUILD_ROLE_NAME = 'Guild Assign issues/PRs'; const COMMUNITY_CHAMPION_LABEL = 'community champion'; const COMMUNITY_CHAMPIONS = [ '0x2CA', @@ -138,7 +140,25 @@ jobs: }; const isStaffMember = (author) => isTeamMember(STAFF_TEAM_SLUG, author); - const isGuildMember = (author) => isTeamMember(GUILD_TEAM_SLUG, author); + + const isGuildMember = async (author) => { + try { + const response = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: 'zed-industries', + repo: 'zed', + username: author + }); + // role_name is the effective (highest) role; for cohort outside + // collaborators that is the custom role. Built-in roles come back + // lowercased and won't match. + return (response.data.role_name || '').toLowerCase() === GUILD_ROLE_NAME.toLowerCase(); + } catch (error) { + if (error.status !== 404) { + throw error; + } + return false; + } + }; const getIssueLabels = () => { if (listIncludesAuthor(COMMUNITY_CHAMPIONS, author)) { diff --git a/script/github-guild-board.py b/script/github-guild-board.py index 1fe4c34baaf..7bdd8ab3a2a 100644 --- a/script/github-guild-board.py +++ b/script/github-guild-board.py @@ -38,7 +38,10 @@ RETRY_DELAY_SECONDS = 5 GITHUB_API_URL = "https://api.github.com" REPO_OWNER = "zed-industries" REPO_NAME = "zed" -GUILD_TEAM_SLUG = "guild-cohort-2" +# Cohort members are outside collaborators on the repo holding this custom +# repository role, rather than members of an org team. Rotating the cohort is +# then just adding/removing collaborators, with no org seats involved. +GUILD_ROLE_NAME = "Guild Assign issues/PRs" STATUS_FIELD = "Status" STATUS_IN_PROGRESS = "In Progress" @@ -137,15 +140,19 @@ def github_rest_get_paginated(path): @lru_cache(maxsize=None) def is_guild_member(username): response = requests.get( - f"{GITHUB_API_URL}/orgs/{REPO_OWNER}/teams/{GUILD_TEAM_SLUG}/memberships/{username}", + f"{GITHUB_API_URL}/repos/{REPO_OWNER}/{REPO_NAME}/collaborators/{username}/permission", headers=GITHUB_HEADERS, timeout=30, ) + # 404 means the user isn't a collaborator on the repo at all. if response.status_code == 404: return False response.raise_for_status() - # A pending invitation reports state "pending"; only active members count. - return response.json().get("state") == "active" + # role_name is the effective (highest) role for the user. For a cohort of + # outside collaborators whose only grant is this custom role, that is the + # custom role's name; built-in roles come back lowercased and won't match. + role_name = response.json().get("role_name") or "" + return role_name.lower() == GUILD_ROLE_NAME.lower() def issue_comments(issue_number): From b9f3396b68c61e27f2aab38fbe4bcd274968befa Mon Sep 17 00:00:00 2001 From: G36maid <53391375+G36maid@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:53:11 +0800 Subject: [PATCH 152/197] Add support for `format_on_save` only changed lines (#49964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Introduces `format_on_save` variants that format only modified lines, limiting formatting to lines changed since the last commit. This prevents massive diffs when editing legacy codebases. Aligns with VS Code's `editor.formatOnSaveMode` naming: - `"modifications"` — formats only git-diffed lines. Requires source control; skips formatting if no diff is available. - `"modifications_if_available"` — formats only git-diffed lines, falling back to full-file formatting for untracked files, when source control is unavailable, or when the LSP does not support range formatting. Also supports importing equivalent VS Code settings (`formatOnSaveMode`). This PR uses the range-based whitespace/newline infrastructure from the dependency PR above. ## Changes
New settings, modified ranges computation, VS Code import ### New settings (`language.rs`, `default.json`, `all-settings.md`) Two new `FormatOnSave` variants: `Modifications` and `ModificationsIfAvailable`. ### Modified ranges computation (`items.rs`) - `compute_format_decision()` — reads `format_on_save` setting across all buffers, determines whether to use ranged formatting (`Ranges`), full formatting (`Full`), or skip (`Skip`). - `compute_modified_ranges()` — extracts modified line ranges from git diff hunks via `BufferDiffSnapshot`. - `is_empty_range()` — helper to detect deletion-only hunks that produce no formatable content. The `save()` method calls `compute_format_decision()` and dispatches accordingly. ### Modifications mode in `lsp_store.rs` - Adds `FormatOnSave::Modifications | FormatOnSave::ModificationsIfAvailable` to the formatter selection match arm. - `ModificationsIfAvailable` falls back to full-file formatting when ranged formatting produces no results. ### VS Code settings import (`vscode_import.rs`, `settings_store.rs`) Imports `editor.formatOnSaveMode` mapping: - `"modifications"` → `FormatOnSave::Modifications` - `"modificationsIfAvailable"` → `FormatOnSave::ModificationsIfAvailable` - `"file"` → `FormatOnSave::On`
## Tests - `test_modifications_format_on_save` — basic modifications mode formatting with dirty buffer - `test_modifications_format_no_changes` — clean buffer triggers no formatting - `test_modifications_format_lsp_no_range_support` — LSP without range formatting skips entirely for `Modifications` - `test_modifications_format_lsp_returns_empty_edits` — empty edits handled gracefully - `test_modifications_format_multiple_hunks` — two non-adjacent edits produce two separate range formatting requests --- 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 #16509 Depends on #53942 Release Notes: - Added `modifications` and `modifications_if_available` options to `format_on_save`, which format only git-changed lines instead of the entire file - When using modifications mode, `remove_trailing_whitespace_on_save` and `ensure_final_newline_on_save` are also scoped to changed lines, preventing unwanted diff jitter in legacy codebases - Added support for importing VS Code's `editor.formatOnSaveMode` setting --------- Co-authored-by: Kirill Bulatov --- assets/settings/default.json | 8 +- crates/editor/src/editor_tests.rs | 1020 ++++++++++++++++- crates/editor/src/items.rs | 258 ++++- .../src/migrations/m_2025_10_02/settings.rs | 6 +- crates/project/src/lsp_store.rs | 57 +- crates/settings/src/settings_store.rs | 142 +++ crates/settings/src/vscode_import.rs | 12 +- crates/settings_content/src/language.rs | 12 +- crates/settings_ui/src/page_data.rs | 2 +- docs/src/reference/all-settings.md | 24 +- 10 files changed, 1507 insertions(+), 34 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 983784e8951..0e42d27e980 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1458,7 +1458,13 @@ // The EditorConfig `end_of_line` property overrides this setting and behaves // like `enforce_lf` or `enforce_crlf`. "line_ending": "detect", - // Whether or not to perform a buffer format before saving: [on, off] + // Whether or not to perform a buffer format before saving: + // "on" — format the whole buffer + // "off" — do not format + // "modifications" — format only lines with unstaged changes; skips formatting + // when no git diff is available or the language server lacks range formatting + // "modifications_if_available" — same, but falls back to formatting the whole + // buffer when range formatting cannot be used // Keep in mind, if the autosave with delay is enabled, format_on_save will be ignored "format_on_save": "off", // How to perform a buffer format. This setting can take multiple values: diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index ca5cc9a6a2f..9b78a9a5c0c 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -29,7 +29,8 @@ use language::{ LanguageConfig, LanguageConfigOverride, LanguageMatcher, LanguageName, LanguageQueries, LanguageToolchainStore, Override, Point, language_settings::{ - CompletionSettingsContent, FormatterList, LanguageSettingsContent, LspInsertMode, + CompletionSettingsContent, FormatOnSave, FormatterList, LanguageSettingsContent, + LspInsertMode, }, tree_sitter_python, }; @@ -15171,6 +15172,89 @@ async fn setup_range_format_test( .await } +/// Like `setup_range_format_test`, but backs the buffer with a FakeFs git +/// repository so that `GitStore::get_unstaged_diff` returns a real diff. +/// `head_content` sets the HEAD base, `index_content` sets the staged base. +/// The buffer starts empty; the caller must `editor.set_text(...)` to set the +/// working-tree content (the diff recomputes from buffer changes). +async fn setup_range_format_test_with_git<'a>( + cx: &'a mut TestAppContext, + head_content: &str, + index_content: &str, +) -> ( + Entity, + Entity, + &'a mut gpui::VisualTestContext, + lsp::FakeLanguageServer, +) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "file.rs": "", + }), + ) + .await; + + fs.set_head_for_repo( + std::path::Path::new(path!("/project/.git")), + &[("file.rs", head_content.to_string())], + "deadbeef", + ); + fs.set_index_for_repo( + std::path::Path::new(path!("/project/.git")), + &[("file.rs", index_content.to_string())], + ); + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_range_formatting_provider: Some(lsp::OneOf::Left(true)), + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/file.rs"), cx) + }) + .await + .unwrap(); + + // Open the unstaged diff so GitStore tracks this buffer. Without this, + // `get_unstaged_diff` returns None and compute_format_target cannot + // produce range-based FormatTarget. + project + .update(cx, |project, cx| { + project.open_unstaged_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + editor.update_in(cx, |editor, window, cx| { + window.focus(&editor.focus_handle(cx), cx); + }); + + let fake_server = fake_servers.next().await.unwrap(); + + (project, editor, cx, fake_server) +} + fn refresh_editor_actions(cx: &mut VisualTestContext) { cx.executor().run_until_parked(); cx.update(|window, cx| { @@ -15383,6 +15467,940 @@ async fn test_range_format_on_save_timeout(cx: &mut TestAppContext) { assert!(!cx.read(|cx| editor.is_dirty(cx))); } +#[gpui::test] +async fn test_modifications_format_on_save(cx: &mut TestAppContext) { + let (project, editor, cx, fake_server) = setup_range_format_test_with_git(cx, "", "").await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("one\ntwo\nthree\n", window, cx) + }); + cx.run_until_parked(); + assert!(cx.read(|cx| editor.is_dirty(cx))); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + fake_server + .set_request_handler::(move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/project/file.rs")).unwrap() + ); + Ok(Some(vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)), + ", ".to_string(), + )])) + }) + .next() + .await; + save.await; + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + "one, two\nthree\n" + ); + assert!(!cx.read(|cx| editor.is_dirty(cx))); +} + +#[gpui::test] +async fn test_modifications_format_skips_without_git_diff(cx: &mut TestAppContext) { + let (project, editor, cx, fake_server) = setup_range_format_test(cx).await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("one\ntwo\nthree\n", window, cx) + }); + assert!( + cx.read(|cx| editor.is_dirty(cx)), + "editor should be dirty before save" + ); + + let _no_range_format_handler = fake_server + .set_request_handler::(move |_, _| async move { + panic!("rangeFormatting must not be called when no git diff is available"); + }) + .next(); + let _no_format_handler = fake_server + .set_request_handler::(move |_, _| async move { + panic!("full formatting must not be called for Modifications without a git diff"); + }) + .next(); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + save.await; + cx.run_until_parked(); + assert!( + !cx.read(|cx| editor.is_dirty(cx)), + "the buffer should be saved despite the skipped formatting" + ); + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + "one\ntwo\nthree\n" + ); +} + +#[gpui::test] +async fn test_modifications_format_lsp_no_range_support(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "file.rs": "", + }), + ) + .await; + + let head_content = "one\ntwo\nthree\n"; + fs.set_head_and_index_for_repo( + std::path::Path::new(path!("/project/.git")), + &[("file.rs", head_content.to_string())], + ); + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_range_formatting_provider: Some(lsp::OneOf::Left(false)), + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/file.rs"), cx) + }) + .await + .unwrap(); + project + .update(cx, |project, cx| { + project.open_unstaged_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + + let fake_server = fake_servers.next().await.unwrap(); + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("one\nTWO\nthree\n", window, cx) + }); + cx.run_until_parked(); + assert!(cx.read(|cx| editor.is_dirty(cx))); + + let _no_range_format_handler = fake_server + .set_request_handler::(move |_, _| async move { + panic!("rangeFormatting must not be called when range formatting is unsupported"); + }) + .next(); + let _no_format_handler = fake_server + .set_request_handler::(move |_, _| async move { + panic!("full formatting must not be called for Modifications when range formatting is unsupported"); + }) + .next(); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + save.await; + cx.run_until_parked(); + assert!(!cx.read(|cx| editor.is_dirty(cx))); + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + "one\nTWO\nthree\n" + ); +} + +#[gpui::test] +async fn test_modifications_format_lsp_returns_empty_edits(cx: &mut TestAppContext) { + let (project, editor, cx, fake_server) = setup_range_format_test_with_git(cx, "", "").await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("aaa\nbbb\nccc\nddd\neee\n", window, cx) + }); + cx.run_until_parked(); + assert!(cx.read(|cx| editor.is_dirty(cx))); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + fake_server + .set_request_handler::(move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/project/file.rs")).unwrap() + ); + Ok(Some(Vec::new())) + }) + .next() + .await; + save.await; + assert!(!cx.read(|cx| editor.is_dirty(cx))); + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + "aaa\nbbb\nccc\nddd\neee\n" + ); +} + +#[gpui::test] +async fn test_modifications_format_multiple_hunks(cx: &mut TestAppContext) { + let head_content = "line0\nline1\nline2\nline3\nline4\nline5\n"; + let (project, editor, cx, fake_server) = + setup_range_format_test_with_git(cx, head_content, head_content).await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("line0\nLINE1\nline2\nline3\nLINE4\nline5\n", window, cx); + }); + cx.run_until_parked(); + assert!(cx.read(|cx| editor.is_dirty(cx))); + + let request_count = Arc::new(AtomicUsize::new(0)); + let request_count_clone = request_count.clone(); + let mut responded_rx = + fake_server.set_request_handler::(move |params, _| { + let count = request_count_clone.fetch_add(1, atomic::Ordering::SeqCst); + async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/project/file.rs")).unwrap() + ); + match count { + 0 => Ok(Some(vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(1, 5), lsp::Position::new(1, 5)), + "!".to_string(), + )])), + 1 => Ok(Some(vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(4, 5), lsp::Position::new(4, 5)), + "!".to_string(), + )])), + _ => panic!("unexpected third range formatting request"), + } + } + }); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + responded_rx.next().await; + responded_rx.next().await; + save.await; + + assert_eq!(request_count.load(atomic::Ordering::SeqCst), 2); + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + "line0\nLINE1!\nline2\nline3\nLINE4!\nline5\n" + ); + assert!(!cx.read(|cx| editor.is_dirty(cx))); +} + +#[gpui::test] +async fn test_modifications_format_excludes_staged_changes(cx: &mut TestAppContext) { + let head_content = "line0\nline1\nline2\nline3\nline4\n"; + let staged_content = "line0\nLINE1\nline2\nline3\nline4\n"; + let (project, editor, cx, fake_server) = + setup_range_format_test_with_git(cx, head_content, staged_content).await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + let working_content = "line0\nLINE1\nline2\nline3\nLINE4\n"; + editor.update_in(cx, |editor, window, cx| { + editor.set_text(working_content, window, cx) + }); + cx.run_until_parked(); + + let request_count = Arc::new(AtomicUsize::new(0)); + let request_count_clone = request_count.clone(); + let mut responded_rx = + fake_server.set_request_handler::(move |params, _| { + request_count_clone.fetch_add(1, atomic::Ordering::SeqCst); + assert_eq!( + params.range.start.line, 4, + "only the unstaged hunk (LINE4) should be formatted" + ); + async move { Ok(Some(Vec::new())) } + }); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + responded_rx.next().await; + save.await; + + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 1, + "staged hunk (LINE1) must not be formatted, only the unstaged hunk (LINE4)" + ); +} + +#[gpui::test] +async fn test_modifications_format_range_excludes_staged_hunk(cx: &mut TestAppContext) { + let head_content = "a\nb\nc\n"; + let staged_content = "A\nb\nc\n"; + let (project, editor, cx, fake_server) = + setup_range_format_test_with_git(cx, head_content, staged_content).await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("A\nB\nc\n", window, cx) + }); + cx.run_until_parked(); + + let captured_start_line = Arc::new(AtomicUsize::new(usize::MAX)); + let captured_start_line_clone = captured_start_line.clone(); + let mut responded_rx = + fake_server.set_request_handler::(move |params, _| { + captured_start_line_clone + .store(params.range.start.line as usize, atomic::Ordering::SeqCst); + async move { Ok(Some(Vec::new())) } + }); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + responded_rx.next().await; + save.await; + + let start_line = captured_start_line.load(atomic::Ordering::SeqCst); + assert_ne!( + start_line, + usize::MAX, + "expected at least one range formatting request" + ); + assert_eq!( + start_line, 1, + "format range must exclude the staged row and start at the unstaged row" + ); +} + +#[gpui::test] +async fn test_modifications_format_pure_unstaged_with_git(cx: &mut TestAppContext) { + let head_content = "line0\nline1\nline2\nline3\nline4\n"; + let (project, editor, cx, fake_server) = + setup_range_format_test_with_git(cx, head_content, head_content).await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + let working_content = "line0\nLINE1\nline2\nline3\nLINE4\n"; + editor.update_in(cx, |editor, window, cx| { + editor.set_text(working_content, window, cx) + }); + cx.run_until_parked(); + + let request_count = Arc::new(AtomicUsize::new(0)); + let request_count_clone = request_count.clone(); + let mut responded_rx = fake_server.set_request_handler::( + move |_params, _| { + request_count_clone.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(Some(Vec::new())) } + }, + ); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + responded_rx.next().await; + responded_rx.next().await; + save.await; + + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 2, + "unstaged hunks (LINE1, LINE4) must be formatted via the git diff path" + ); +} + +#[gpui::test] +async fn test_modifications_format_no_unstaged_changes_with_git(cx: &mut TestAppContext) { + let head_content = "line0\nline1\nline2\nline3\nline4\n"; + let staged_content = "line0\nLINE1\nline2\nline3\nLINE4\n"; + let (project, editor, cx, fake_server) = + setup_range_format_test_with_git(cx, head_content, staged_content).await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text(staged_content, window, cx) + }); + cx.run_until_parked(); + + let _no_format_handler = fake_server + .set_request_handler::(move |_, _| async move { + panic!("range formatting must not be called when all changes are staged"); + }) + .next(); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + save.await; + cx.run_until_parked(); +} + +#[gpui::test] +async fn test_modifications_format_no_changes_with_git(cx: &mut TestAppContext) { + let head_content = "line0\nline1\nline2\n"; + let (project, editor, cx, fake_server) = + setup_range_format_test_with_git(cx, head_content, head_content).await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text(head_content, window, cx) + }); + cx.run_until_parked(); + + let _no_format_handler = fake_server + .set_request_handler::(move |_, _| async move { + panic!("range formatting must not be called when buffer matches HEAD"); + }) + .next(); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + save.await; + cx.run_until_parked(); +} + +#[gpui::test] +async fn test_modifications_format_crlf_line_endings(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "file.rs": "line0\r\nline1\r\nline2\r\nline3\r\nline4\r\n", + }), + ) + .await; + + let head_content = "line0\nline1\nline2\nline3\nline4\n"; + fs.set_head_for_repo( + std::path::Path::new(path!("/project/.git")), + &[("file.rs", head_content.to_string())], + "deadbeef", + ); + fs.set_index_for_repo( + std::path::Path::new(path!("/project/.git")), + &[("file.rs", head_content.to_string())], + ); + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_range_formatting_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/file.rs"), cx) + }) + .await + .unwrap(); + + buffer.read_with(cx, |buffer, _| { + assert_eq!( + buffer.line_ending(), + language::LineEnding::Windows, + "buffer should detect CRLF line endings from the working tree file" + ); + }); + + project + .update(cx, |project, cx| { + project.open_unstaged_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + editor.update_in(cx, |editor, window, cx| { + window.focus(&editor.focus_handle(cx), cx); + }); + + let fake_server = fake_servers.next().await.unwrap(); + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("line0\nLINE1\nline2\nline3\nLINE4\n", window, cx) + }); + cx.run_until_parked(); + + let request_count = Arc::new(AtomicUsize::new(0)); + let request_count_clone = request_count.clone(); + let mut responded_rx = fake_server.set_request_handler::( + move |_params, _| { + request_count_clone.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(Some(Vec::new())) } + }, + ); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + responded_rx.next().await; + responded_rx.next().await; + save.await; + + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 2, + "CRLF line endings must not cause spurious diff hunks" + ); +} + +#[gpui::test] +async fn test_modifications_format_merge_boundary_one_row_gap(cx: &mut TestAppContext) { + let head_content = "line0\nline1\nline2\nline3\nline4\n"; + let (project, editor, cx, fake_server) = + setup_range_format_test_with_git(cx, head_content, head_content).await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::Modifications); + }); + + let working_content = "line0\nLINE1\nline2\nLINE3\nline4\n"; + editor.update_in(cx, |editor, window, cx| { + editor.set_text(working_content, window, cx) + }); + cx.run_until_parked(); + + let request_count = Arc::new(AtomicUsize::new(0)); + let request_count_clone = request_count.clone(); + let mut responded_rx = fake_server.set_request_handler::( + move |_params, _| { + request_count_clone.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(Some(Vec::new())) } + }, + ); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + responded_rx.next().await; + responded_rx.next().await; + save.await; + + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 2, + "hunks separated by exactly one unchanged row must not merge" + ); +} + +#[gpui::test] +async fn test_modifications_if_available_empty_diff_skips_formatting(cx: &mut TestAppContext) { + let head_content = "line0\nline1\nline2\n"; + let (project, editor, cx, fake_server) = + setup_range_format_test_with_git(cx, head_content, head_content).await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::ModificationsIfAvailable); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text(head_content, window, cx) + }); + cx.run_until_parked(); + + let _no_range_format = fake_server + .set_request_handler::(move |_, _| async move { + panic!("range formatting must not be called when diff is empty"); + }) + .next(); + let _no_format = fake_server + .set_request_handler::(move |_, _| async move { + panic!("full formatting must not be called when a diff is available but empty"); + }) + .next(); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + save.await; + cx.run_until_parked(); + assert!( + !cx.read(|cx| editor.is_dirty(cx)), + "the buffer should be saved despite the skipped formatting" + ); +} + +#[gpui::test] +async fn test_modifications_if_available_no_git_falls_back_to_full_format(cx: &mut TestAppContext) { + let (project, editor, cx, fake_server) = setup_range_format_test_with_capabilities( + cx, + lsp::ServerCapabilities { + document_range_formatting_provider: Some(lsp::OneOf::Left(true)), + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + ) + .await; + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::ModificationsIfAvailable); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("one\ntwo\nthree\n", window, cx) + }); + assert!(cx.read(|cx| editor.is_dirty(cx))); + + let _no_range_format = fake_server + .set_request_handler::(move |_, _| async move { + panic!("range formatting must not be called when no git diff is available"); + }) + .next(); + + let formatting_called = Arc::new(AtomicBool::new(false)); + let formatting_called_clone = formatting_called.clone(); + let mut formatting_rx = + fake_server.set_request_handler::(move |_, _| { + formatting_called_clone.store(true, atomic::Ordering::SeqCst); + async move { Ok(Some(Vec::new())) } + }); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + formatting_rx.next().await; + save.await; + + assert!( + formatting_called.load(atomic::Ordering::SeqCst), + "ModificationsIfAvailable must fall back to full-buffer formatting when no git diff is available" + ); +} + +#[gpui::test] +async fn test_modifications_if_available_lsp_no_range_support_falls_back_to_full_format( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "file.rs": "", + }), + ) + .await; + + let head_content = "line0\nline1\nline2\n"; + fs.set_head_for_repo( + std::path::Path::new(path!("/project/.git")), + &[("file.rs", head_content.to_string())], + "deadbeef", + ); + fs.set_index_for_repo( + std::path::Path::new(path!("/project/.git")), + &[("file.rs", head_content.to_string())], + ); + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_range_formatting_provider: Some(lsp::OneOf::Left(false)), + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/file.rs"), cx) + }) + .await + .unwrap(); + project + .update(cx, |project, cx| { + project.open_unstaged_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| { + build_editor_with_project(project.clone(), buffer, window, cx) + }); + editor.update_in(cx, |editor, window, cx| { + window.focus(&editor.focus_handle(cx), cx); + }); + + let fake_server = fake_servers.next().await.unwrap(); + + update_test_language_settings(cx, &|settings| { + settings.defaults.format_on_save = Some(FormatOnSave::ModificationsIfAvailable); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("line0\nLINE1\nline2\n", window, cx); + }); + cx.run_until_parked(); + assert!(cx.read(|cx| editor.is_dirty(cx))); + + let _no_range_format = fake_server + .set_request_handler::(move |_, _| async move { + panic!("rangeFormatting must not be called when LSP lacks range support"); + }) + .next(); + + let formatting_called = Arc::new(AtomicBool::new(false)); + let formatting_called_clone = formatting_called.clone(); + let mut formatting_rx = + fake_server.set_request_handler::(move |_, _| { + formatting_called_clone.store(true, atomic::Ordering::SeqCst); + async move { Ok(Some(Vec::new())) } + }); + + let save = editor + .update_in(cx, |editor, window, cx| { + editor.save( + SaveOptions { + format: true, + autosave: false, + force_format: false, + }, + project.clone(), + window, + cx, + ) + }) + .unwrap(); + formatting_rx.next().await; + save.await; + + assert!( + formatting_called.load(atomic::Ordering::SeqCst), + "ModificationsIfAvailable must fall back to full-file formatting when the LSP lacks range support" + ); +} + #[gpui::test] async fn test_range_format_not_called_for_clean_buffer(cx: &mut TestAppContext) { let (project, editor, cx, fake_server) = setup_range_format_test(cx).await; diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 4e97c8c3fe3..4acdb10ff75 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -19,12 +19,14 @@ use gpui::{ }; use language::{ Bias, Buffer, BufferRow, CharKind, CharScopeContext, HighlightedText, LocalFile, PLAIN_TEXT, - Point, SelectionGoal, proto::serialize_anchor as serialize_text_anchor, + Point, SelectionGoal, + language_settings::{FormatOnSave, LanguageSettings}, + proto::serialize_anchor as serialize_text_anchor, }; use lsp::DiagnosticSeverity; use multi_buffer::{BufferOffset, MultiBufferOffset, PathKey}; use project::{ - File, Project, ProjectItem as _, ProjectPath, lsp_store::FormatTrigger, + File, Project, ProjectItem as _, ProjectPath, git_store::GitStore, lsp_store::FormatTrigger, project_settings::ProjectSettings, search::SearchQuery, }; use rope::TextSummary; @@ -39,7 +41,7 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; -use text::{BufferId, BufferSnapshot, OffsetRangeExt, Selection}; +use text::{BufferId, BufferSnapshot, OffsetRangeExt, Selection, ToPoint as _}; use ui::{IconDecorationKind, prelude::*}; use util::{ResultExt, TryFutureExt, paths::PathExt, rel_path::RelPath}; use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams}; @@ -974,16 +976,21 @@ impl Item for Editor { cx.spawn_in(window, async move |this, cx| { if options.format { - this.update_in(cx, |editor, window, cx| { - editor.perform_format( - project.clone(), + let format_task = this.update_in(cx, |editor, window, cx| { + let format_target = compute_format_target( + &buffers_to_save, format_trigger, - FormatTarget::Buffers(buffers_to_save.clone()), - window, + editor.buffer(), + project.read(cx).git_store(), cx, - ) - })? - .await?; + ); + format_target.map(|target| { + editor.perform_format(project.clone(), format_trigger, target, window, cx) + }) + })?; + if let Some(format_task) = format_task { + format_task.await?; + } } if !buffers_to_save.is_empty() { @@ -2267,6 +2274,115 @@ fn chunk_search_range( })) } +/// Decides what to format based on the `format_on_save` settings of the saved buffers. +/// +/// In the modifications modes, only lines with unstaged changes are formatted. +/// When no git diff is available for a buffer, `modifications` skips formatting while `modifications_if_available` +/// falls back to formatting entire buffers. +/// When a diff is available but empty, nothing is formatted in either mode. +fn compute_format_target( + buffers: &HashSet>, + trigger: FormatTrigger, + multi_buffer: &Entity, + git_store: &Entity, + cx: &App, +) -> Option { + if trigger == FormatTrigger::Manual { + return Some(FormatTarget::Buffers(buffers.clone())); + } + + let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx); + let git_store = git_store.read(cx); + + let mut fall_back_to_full_format = false; + let mut modified_ranges: Vec> = Vec::new(); + + for buffer_entity in buffers.iter() { + let buffer = buffer_entity.read(cx); + let settings = LanguageSettings::for_buffer(buffer, cx); + match settings.format_on_save { + FormatOnSave::On | FormatOnSave::Off => { + return Some(FormatTarget::Buffers(buffers.clone())); + } + FormatOnSave::Modifications | FormatOnSave::ModificationsIfAvailable => {} + } + + let Some(diff_snapshot) = git_store + .get_unstaged_diff(buffer.remote_id(), cx) + .map(|diff| diff.read(cx).snapshot(cx)) + else { + if settings.format_on_save == FormatOnSave::ModificationsIfAvailable { + fall_back_to_full_format = true; + } + continue; + }; + + let anchor_ranges = compute_modified_ranges(&buffer.snapshot(), &diff_snapshot); + let flat_anchors = anchor_ranges + .iter() + .flat_map(|range| [range.start, range.end]) + .collect::>(); + let multi_buffer_anchors = + multi_buffer_snapshot.text_anchors_to_visible_anchors(flat_anchors); + for pair in multi_buffer_anchors.chunks_exact(2) { + let (Some(start), Some(end)) = (&pair[0], &pair[1]) else { + continue; + }; + modified_ranges + .push(start.to_point(&multi_buffer_snapshot)..end.to_point(&multi_buffer_snapshot)); + } + } + + if fall_back_to_full_format { + Some(FormatTarget::Buffers(buffers.clone())) + } else if modified_ranges.is_empty() { + None + } else { + Some(FormatTarget::Ranges(modified_ranges)) + } +} + +/// Computes the buffer ranges that have unstaged changes, expanded to full lines and +/// with adjacent hunks merged, for use with format-on-save. An empty result means the +/// buffer has no formatable modifications. +fn compute_modified_ranges( + buffer_snapshot: &language::BufferSnapshot, + diff_snapshot: &buffer_diff::BufferDiffSnapshot, +) -> Vec> { + let mut merged: Vec> = Vec::new(); + for hunk in diff_snapshot.hunks(buffer_snapshot) { + let range = hunk.buffer_range; + if range.start.cmp(&range.end, buffer_snapshot).is_eq() { + // Deletion-only hunks produce no buffer content to format. + continue; + } + let start_point = range.start.to_point(buffer_snapshot); + let end_point = range.end.to_point(buffer_snapshot); + let start_row = start_point.row; + let end_row = if end_point.column == 0 && end_point.row > start_point.row { + end_point.row - 1 + } else { + end_point.row + }; + let line_start = text::Point::new(start_row, 0); + let line_end = text::Point::new(end_row, buffer_snapshot.line_len(end_row)); + let expanded = + buffer_snapshot.anchor_before(line_start)..buffer_snapshot.anchor_after(line_end); + + if let Some(last) = merged.last_mut() { + let last_end_point = last.end.to_point(buffer_snapshot); + if start_row <= last_end_point.row + 1 { + if expanded.end.to_point(buffer_snapshot) > last_end_point { + last.end = expanded.end; + } + continue; + } + } + merged.push(expanded); + } + merged +} + #[cfg(test)] mod tests { use crate::editor_tests::init_test; @@ -2921,4 +3037,124 @@ mod tests { "Editor::deserialize should not add items to panes as a side effect" ); } + + #[gpui::test] + fn test_compute_modified_ranges_git_diff(cx: &mut gpui::TestAppContext) { + let base_text = "line0\nline1\nline2\nline3\nline4\nline5\nline6\n"; + // Modify line1 and line5 to create two non-adjacent hunks. + let buffer_text = "line0\nMOD1\nline2\nline3\nline4\nMOD5\nline6\n"; + + let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx)); + let diff_snapshot = buffer.update(cx, |buffer, cx| { + let diff = cx.new(|cx| { + buffer_diff::BufferDiff::new_with_base_text(base_text, &buffer.text_snapshot(), cx) + }); + diff.read(cx).snapshot(cx) + }); + + let ranges = buffer.update(cx, |buffer, _cx| { + compute_modified_ranges(&buffer.snapshot(), &diff_snapshot) + }); + + assert_eq!(ranges.len(), 2, "expected 2 non-adjacent ranges"); + + buffer.update(cx, |buffer, _cx| { + let text_snapshot: &text::BufferSnapshot = buffer; + let r0 = ranges[0].start.to_point(text_snapshot)..ranges[0].end.to_point(text_snapshot); + let r1 = ranges[1].start.to_point(text_snapshot)..ranges[1].end.to_point(text_snapshot); + assert_eq!(r0.start.row, 1, "first hunk should start at row 1"); + assert_eq!(r0.end.row, 1, "first hunk should end at row 1"); + assert_eq!(r1.start.row, 5, "second hunk should start at row 5"); + assert_eq!(r1.end.row, 5, "second hunk should end at row 5"); + }); + } + + #[gpui::test] + fn test_compute_modified_ranges_unchanged_buffer(cx: &mut gpui::TestAppContext) { + let buffer_text = "line0\nline1\nline2\n"; + let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx)); + let diff_snapshot = buffer.update(cx, |buffer, cx| { + let diff = cx.new(|cx| { + buffer_diff::BufferDiff::new_with_base_text( + buffer_text, + &buffer.text_snapshot(), + cx, + ) + }); + diff.read(cx).snapshot(cx) + }); + + let ranges = buffer.update(cx, |buffer, _cx| { + compute_modified_ranges(&buffer.snapshot(), &diff_snapshot) + }); + + assert_eq!( + ranges, + Vec::new(), + "buffer that matches its diff base should produce no modified ranges" + ); + } + + #[gpui::test] + fn test_compute_modified_ranges_deletion_only(cx: &mut gpui::TestAppContext) { + let base_text = "line0\nline1\nline2\n"; + // Buffer has line1 deleted (pure deletion). + let buffer_text = "line0\nline2\n"; + + let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx)); + let diff_snapshot = buffer.update(cx, |buffer, cx| { + let diff = cx.new(|cx| { + buffer_diff::BufferDiff::new_with_base_text(base_text, &buffer.text_snapshot(), cx) + }); + diff.read(cx).snapshot(cx) + }); + + // Verify the diff has a deletion hunk. + let hunk_count = buffer.update(cx, |buffer, _cx| { + let text_snapshot: &text::BufferSnapshot = buffer; + diff_snapshot.hunks(text_snapshot).count() + }); + assert!(hunk_count > 0, "diff should have hunks"); + + let ranges = buffer.update(cx, |buffer, _cx| { + compute_modified_ranges(&buffer.snapshot(), &diff_snapshot) + }); + + assert_eq!( + ranges, + Vec::new(), + "deletion-only hunks should be skipped, leaving no ranges" + ); + } + + #[gpui::test] + fn test_compute_modified_ranges_adjacent_hunks(cx: &mut gpui::TestAppContext) { + let base_text = "line0\nline1\nline2\nline3\nline4\n"; + // Modify lines 2 and 3 which are adjacent; they should merge into one range. + let buffer_text = "line0\nline1\nMOD2\nMOD3\nline4\n"; + + let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx)); + let diff_snapshot = buffer.update(cx, |buffer, cx| { + let diff = cx.new(|cx| { + buffer_diff::BufferDiff::new_with_base_text(base_text, &buffer.text_snapshot(), cx) + }); + diff.read(cx).snapshot(cx) + }); + + let ranges = buffer.update(cx, |buffer, _cx| { + compute_modified_ranges(&buffer.snapshot(), &diff_snapshot) + }); + + assert_eq!( + ranges.len(), + 1, + "adjacent hunks (rows 2 and 3) should be merged into one range" + ); + buffer.update(cx, |buffer, _cx| { + let text_snapshot: &text::BufferSnapshot = buffer; + let r = ranges[0].start.to_point(text_snapshot)..ranges[0].end.to_point(text_snapshot); + assert_eq!(r.start.row, 2, "merged range should start at row 2"); + assert_eq!(r.end.row, 3, "merged range should end at row 3"); + }); + } } diff --git a/crates/migrator/src/migrations/m_2025_10_02/settings.rs b/crates/migrator/src/migrations/m_2025_10_02/settings.rs index 8942008e632..bb2a5bbb299 100644 --- a/crates/migrator/src/migrations/m_2025_10_02/settings.rs +++ b/crates/migrator/src/migrations/m_2025_10_02/settings.rs @@ -14,9 +14,9 @@ fn remove_formatters_on_save_inner(value: &mut Value, path: &[&str]) -> Result<( let Some(format_on_save) = obj.get("format_on_save").cloned() else { return Ok(()); }; - let is_format_on_save_set_to_formatter = format_on_save - .as_str() - .map_or(true, |s| s != "on" && s != "off"); + let is_format_on_save_set_to_formatter = format_on_save.as_str().map_or(true, |s| { + s != "on" && s != "off" && s != "modifications" && s != "modifications_if_available" + }); if !is_format_on_save_set_to_formatter { return Ok(()); } diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 1555a25dd21..f9e563ccdb3 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -1733,9 +1733,13 @@ impl LocalLspStore { let formatters = match (trigger, &settings.format_on_save) { (FormatTrigger::Save, FormatOnSave::Off) => &[], - (FormatTrigger::Manual, _) | (FormatTrigger::Save, FormatOnSave::On) => { - settings.formatter.as_ref() - } + (FormatTrigger::Manual, _) + | ( + FormatTrigger::Save, + FormatOnSave::On + | FormatOnSave::Modifications + | FormatOnSave::ModificationsIfAvailable, + ) => settings.formatter.as_ref(), }; let formatters = code_actions_on_format_formatters @@ -1763,6 +1767,7 @@ impl LocalLspStore { &adapters_and_servers, &settings, request_timeout, + trigger, logger, cx, ) @@ -1783,6 +1788,7 @@ impl LocalLspStore { adapters_and_servers: &[(Arc, Arc)], settings: &LanguageSettings, request_timeout: Duration, + trigger: FormatTrigger, logger: zlog::Logger, cx: &mut AsyncApp, ) -> anyhow::Result<()> { @@ -1925,23 +1931,22 @@ impl LocalLspStore { }; let Some(language_server) = language_server else { - log::debug!( - "No language server found to format buffer '{:?}'. Skipping", - buffer_path_abs.as_path().to_string_lossy() + zlog::debug!( + logger => + "No language server found to format buffer {buffer_path_abs:?}. Skipping", ); return Ok(()); }; zlog::trace!( logger => - "Formatting buffer '{:?}' using language server '{:?}'", - buffer_path_abs.as_path().to_string_lossy(), + "Formatting buffer {buffer_path_abs:?} using language server {:?}", language_server.name() ); let edits = if let Some(ranges) = buffer.ranges.as_ref() { zlog::trace!(logger => "formatting ranges"); - Self::format_ranges_via_lsp( + let range_edits = Self::format_ranges_via_lsp( &lsp_store, &buffer.handle, ranges, @@ -1951,8 +1956,38 @@ impl LocalLspStore { cx, ) .await - .context("Failed to format ranges via language server")? - .unwrap_or_default() + .context("Failed to format ranges via language server")?; + + match range_edits { + Some(edits) => edits, + None => { + if trigger == FormatTrigger::Save + && settings.format_on_save == FormatOnSave::ModificationsIfAvailable + { + zlog::debug!( + logger => + "Falling back to full format - LSP does not support range formatting" + ); + Self::format_via_lsp( + &lsp_store, + &buffer.handle, + buffer_path_abs, + &language_server, + &settings, + cx, + ) + .await + .context("failed to format via language server")? + } else { + zlog::debug!( + logger => + "Skipping range format - language server {:?} does not support range formatting", + language_server.name() + ); + Vec::new() + } + } + } } else { zlog::trace!(logger => "formatting full"); Self::format_via_lsp( diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index 29d9f91dc6b..ae63dbe75fc 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -2461,6 +2461,148 @@ mod tests { .unindent(), cx, ); + + // formatOnSave: true with formatOnSaveMode: modificationsIfAvailable + check_vscode_import( + &mut store, + r#"{ + } + "# + .unindent(), + r#"{ "editor.formatOnSave": true, "editor.formatOnSaveMode": "modificationsIfAvailable" }"# + .to_owned(), + r#"{ + "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, + "format_on_save": "modifications_if_available" + } + "# + .unindent(), + cx, + ); + + // formatOnSave: true with formatOnSaveMode: modifications + check_vscode_import( + &mut store, + r#"{ + } + "# + .unindent(), + r#"{ "editor.formatOnSave": true, "editor.formatOnSaveMode": "modifications" }"# + .to_owned(), + r#"{ + "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, + "format_on_save": "modifications" + } + "# + .unindent(), + cx, + ); + + // formatOnSave: true with formatOnSaveMode: file + check_vscode_import( + &mut store, + r#"{ + } + "# + .unindent(), + r#"{ "editor.formatOnSave": true, "editor.formatOnSaveMode": "file" }"#.to_owned(), + r#"{ + "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, + "format_on_save": "on" + } + "# + .unindent(), + cx, + ); + + // formatOnSaveMode is ignored when formatOnSave is disabled, as in VS Code + check_vscode_import( + &mut store, + r#"{ + } + "# + .unindent(), + r#"{ "editor.formatOnSave": false, "editor.formatOnSaveMode": "modifications" }"# + .to_owned(), + r#"{ + "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, + "format_on_save": "off" + } + "# + .unindent(), + cx, + ); + + // formatOnSaveMode alone does nothing, as formatOnSave defaults to false in VS Code + check_vscode_import( + &mut store, + r#"{ + } + "# + .unindent(), + r#"{ "editor.formatOnSaveMode": "modifications" }"#.to_owned(), + r#"{ + "base_keymap": "VSCode", + "minimap": { + "show": "always" + } + } + "# + .unindent(), + cx, + ); + + // formatOnSaveMode not set, formatOnSave: true + check_vscode_import( + &mut store, + r#"{ + } + "# + .unindent(), + r#"{ "editor.formatOnSave": true }"#.to_owned(), + r#"{ + "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, + "format_on_save": "on" + } + "# + .unindent(), + cx, + ); + + // formatOnSaveMode not set, formatOnSave: false + check_vscode_import( + &mut store, + r#"{ + } + "# + .unindent(), + r#"{ "editor.formatOnSave": false }"#.to_owned(), + r#"{ + "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, + "format_on_save": "off" + } + "# + .unindent(), + cx, + ); } #[track_caller] diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 9b8584c4e6b..0d2f5511430 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -554,9 +554,15 @@ impl VsCodeSettings { extend_comment_on_newline: None, extend_list_on_newline: None, indent_list_on_tab: None, - format_on_save: self.read_bool("editor.guides.formatOnSave").map(|b| { - if b { - FormatOnSave::On + // In VS Code, `editor.formatOnSaveMode` only applies when `editor.formatOnSave` is enabled. + format_on_save: self.read_bool("editor.formatOnSave").map(|enabled| { + if enabled { + self.read_enum("editor.formatOnSaveMode", |s| match s { + "modificationsIfAvailable" => Some(FormatOnSave::ModificationsIfAvailable), + "modifications" => Some(FormatOnSave::Modifications), + _ => None, + }) + .unwrap_or(FormatOnSave::On) } else { FormatOnSave::Off } diff --git a/crates/settings_content/src/language.rs b/crates/settings_content/src/language.rs index f31ef08e1d2..71beec78f7d 100644 --- a/crates/settings_content/src/language.rs +++ b/crates/settings_content/src/language.rs @@ -923,12 +923,22 @@ pub struct PrettierSettingsContent { strum::VariantArray, strum::VariantNames, )] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "snake_case")] pub enum FormatOnSave { /// Files should be formatted on save. On, /// Files should not be formatted on save. Off, + /// Only lines with unstaged changes are formatted on save. + /// Requires source control and LSP range formatting support. + /// If no git diff is available or if the LSP doesn't support + /// range formatting, formatting is skipped. + Modifications, + /// Only lines with unstaged changes are formatted on save. + /// If no git diff is available (e.g., when source control is + /// unavailable) or if the LSP doesn't support range formatting, + /// falls back to formatting the whole file. + ModificationsIfAvailable, } /// Controls how line endings are normalized when a buffer is saved. diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index ba35c4875ef..2f6ce0beec5 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -8934,7 +8934,7 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { SettingsPageItem::SectionHeader("Formatting"), SettingsPageItem::SettingItem(SettingItem { title: "Format On Save", - description: "Whether or not to perform a buffer format before saving.", + description: "On: format the whole buffer.\nOff: do not format.\nModifications: format only lines with unstaged changes; skips formatting when a git diff or LSP range formatting is unavailable.\nModifications If Available: same, but falls back to formatting the whole buffer.", field: Box::new( // TODO(settings_ui): this setting should just be a bool SettingField { diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 1450b58d6fc..2ded300ce5d 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -1905,7 +1905,7 @@ While other options may be changed at a runtime and should be placed under `sett } ``` -## Format On Save +## Format On Save {#format-on-save} - Description: Whether or not to perform a buffer format before saving. - Setting: `format_on_save` @@ -1929,6 +1929,26 @@ While other options may be changed at a runtime and should be placed under `sett } ``` +3. `modifications`, formats only lines with unstaged changes: + +```json [settings] +{ + "format_on_save": "modifications" +} +``` + +This mode requires source control and LSP range formatting support. If no git diff is available or if the LSP doesn't support range formatting, formatting is skipped. This is useful for editing legacy codebases where you want to avoid formatting changes in unrelated code. + +4. `modifications_if_available`, formats only modified lines with fallback to full file formatting: + +```json [settings] +{ + "format_on_save": "modifications_if_available" +} +``` + +Similar to `modifications`, but behaves like `on` when range formatting cannot be applied: when no git diff is available (e.g., when source control is unavailable) or when the language server does not support range formatting. When a git diff is available but contains no unstaged changes, nothing is formatted. + ## Formatter - Description: How to perform a buffer format. @@ -3279,7 +3299,7 @@ Examples: - Description: Preview tabs allow you to open files in preview mode, where they close automatically when you switch to another file unless you explicitly pin them. This is useful for quickly viewing files without cluttering your workspace. Preview tabs display their file names in italics. \ - There are several ways to convert a preview tab into a regular tab: + There are several ways to convert a preview tab into a regular tab: - Double-clicking on the file - Double-clicking on the tab header From 6b733d105896a20924bd4aba87bd7baa20b62ac6 Mon Sep 17 00:00:00 2001 From: Keith Hall Date: Wed, 8 Jul 2026 19:56:45 +0300 Subject: [PATCH 153/197] search: Bump fancy-regex dependency and enable CRLF mode (#55471) 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 #43396 Release Notes: - Project search now supports CRLF line endings correctly, as well as other regex features like subroutine calls --- Cargo.lock | 10 ++++----- Cargo.toml | 2 +- crates/project/src/search.rs | 1 + crates/project/tests/integration/search.rs | 26 +++++++++++++++++++++- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b9b4ea96158..3f71a7000d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6465,9 +6465,9 @@ dependencies = [ [[package]] name = "fancy-regex" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" dependencies = [ "bit-set 0.8.0", "regex-automata", @@ -14003,7 +14003,7 @@ dependencies = [ "dap", "encoding_rs", "extension", - "fancy-regex 0.17.0", + "fancy-regex 0.18.0", "fs", "futures 0.3.32", "fuzzy", @@ -15066,9 +15066,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", diff --git a/Cargo.toml b/Cargo.toml index 7a7003ec5eb..916cb147fa5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -595,7 +595,7 @@ emojis = "0.6.1" env_logger = "0.11" encoding_rs = "0.8" exec = "0.3.1" -fancy-regex = "0.17.0" +fancy-regex = "0.18.0" fork = "0.4.0" futures = "0.3.32" futures-concurrency = "7.7.1" diff --git a/crates/project/src/search.rs b/crates/project/src/search.rs index c2804f853a3..ccb38746a2b 100644 --- a/crates/project/src/search.rs +++ b/crates/project/src/search.rs @@ -251,6 +251,7 @@ impl SearchQuery { let regex = RegexBuilder::new(&pattern) .case_insensitive(!case_sensitive) + .crlf(true) .build()?; Ok(Self::Regex { regex, diff --git a/crates/project/tests/integration/search.rs b/crates/project/tests/integration/search.rs index 79266405084..98cf7dc90ab 100644 --- a/crates/project/tests/integration/search.rs +++ b/crates/project/tests/integration/search.rs @@ -1,3 +1,4 @@ +use language::Buffer; use project::search::SearchQuery; use text::Rope; use util::{ @@ -130,6 +131,30 @@ fn test_case_sensitive_pattern_items() { ); } +#[gpui::test] +async fn test_multiline_regex_crlf(cx: &mut gpui::TestAppContext) { + let search_query = SearchQuery::regex( + "^hello$\r?\n", + false, + false, + false, + false, + Default::default(), + Default::default(), + false, + None, + ) + .expect("Should be able to create a regex SearchQuery"); + + let text = Rope::from("hello\r\nworld\r\nhello\r\nworld"); + let snapshot = cx + .update(|app| Buffer::build_snapshot(text, None, None, None, app)) + .await; + + let results = search_query.search(&snapshot, None).await; + assert_eq!(results, vec![0..7, 14..21]); +} + #[gpui::test] async fn test_multiline_regex(cx: &mut gpui::TestAppContext) { let search_query = SearchQuery::regex( @@ -145,7 +170,6 @@ async fn test_multiline_regex(cx: &mut gpui::TestAppContext) { ) .expect("Should be able to create a regex SearchQuery"); - use language::Buffer; let text = Rope::from("hello\nworld\nhello\nworld"); let snapshot = cx .update(|app| Buffer::build_snapshot(text, None, None, None, app)) From 546a16d64fa589c737a9e33dff02a129a673b21d Mon Sep 17 00:00:00 2001 From: Philipp Schaffrath Date: Wed, 8 Jul 2026 21:22:23 +0200 Subject: [PATCH 154/197] gpui: Add parent-anchored native popup windows (with wayland xdg_popup implementation only so far) (#60232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective gpui can't show UI that extends past the window it belongs to. Menus, dropdowns and tooltips are drawn as elements inside the window, so they clip at its edges. This PR adds a window kind for platform-native popups anchored to a parent window, as groundwork for real native menus, dropdowns and tooltips. ## Solution `WindowKind::AnchoredPopup(PopupOptions)` opens a popup positioned relative to a parent window. Instead of giving the popup an absolute position, you describe where it should go and the platform figures out the rest: - `parent`: the window to anchor to - `anchor_rect`: a rectangle in the parent, e.g. the button that opened the menu - `anchor` and `gravity`: which point of that rect to attach to, and which direction to grow - `constraint_adjustment`: what the platform may do if the popup would leave the screen (slide, flip, resize) - `grab`: menu behavior, the popup takes focus and is dismissed when clicking outside the app The popup's size comes from `WindowOptions::window_bounds`. This model mirrors Wayland's `xdg_positioner`, where the compositor owns positioning and the client can only describe intent. Since that's the most restrictive case, the other platforms can implement the same description later with simple math against screen bounds. Only Wayland is implemented so far, via `xdg_popup` on top of the existing surface implementation. Popups can be parented to toplevels, layer-shell surfaces (a menu opened from a panel) and other popups (nested menus). macOS, Windows, X11 and web reject the kind with `PopupNotSupportedError`, so callers can detect that and fall back to in-window popovers. Some Wayland details that might help during review: - Anchor rects are translated from gpui coordinates into the parent's window geometry space and clamped to it. A rect outside the geometry, or with zero size, is a fatal protocol error - Resizing a mapped popup goes through `xdg_popup.reposition` - Mouse press serials are now recorded on press only, not release. Compositors decline grabs and interactive moves that reference a release serial ## Testing Tested manually on Wayland with an example app: the menu opens anchored below its button, extends past the parent window, flips above the button near the bottom of the screen, and a grabbing popup is dismissed when clicking into another application. Nested menus were tested in one of my projects (ignore that they are ugly, that's just a prototype :stuck_out_tongue:): https://github.com/user-attachments/assets/2cd3e2e9-87f7-4b02-986f-48e5633e205c I also have a complete runnable example demonstrating it. I did not add it to the PR, because this might give the impression that `WindowKind::AnchoredPopup` are a complete implementation, despite only working on wayland so far:
Click to view example ```rust //! Example and manual test for platform-native popups (`WindowKind::AnchoredPopup`). //! //! A native popup is a real, parent-anchored window that can extend beyond its parent onto the //! screen, unlike gpui's in-window popovers. Run it, open the menu, and confirm the points listed //! in the window. On a platform without an implementation the button reports that popups are not //! supported instead of opening anything. //! //! Run with: cargo run -p gpui --example popup #![cfg_attr(target_family = "wasm", no_main)] use gpui::{ AnyWindowHandle, App, Bounds, Context, MouseButton, SharedString, Window, WindowBounds, WindowHandle, WindowKind, WindowOptions, div, point, popup::*, prelude::*, px, rgb, size, }; use gpui_platform::application; /// The trigger button, at a fixed position so the popup can anchor to a known rectangle. Real code /// would anchor to the measured bounds of whatever element opens the popup. const BUTTON_BOUNDS: Bounds = Bounds { origin: point(px(24.), px(24.)), size: size(px(200.), px(32.)), }; const POPUP_SIZE: gpui::Size = size(px(260.), px(320.)); struct Menu; impl Render for Menu { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { let item = |label: &str| { div() .id(label.to_string()) .px_3() .py_1() .rounded_sm() .hover(|this| this.bg(rgb(0x3a3a3a))) .cursor_pointer() .child(label.to_string()) .on_click(|_, window, _| window.remove_window()) }; div() .id("menu-root") .size_full() .p_1() .flex() .flex_col() .gap_0p5() .bg(rgb(0x2a2a2a)) .text_color(gpui::white()) .rounded_md() .border_1() .border_color(rgb(0x454545)) .child(item("Foo")) .child(item("Bar")) .child(item("Baz")) .child(item("Qux")) .child(item("Alice")) .child(item("Bob")) } } struct PopupExample { menu: Option>, status: SharedString, } impl Default for PopupExample { fn default() -> Self { Self { menu: None, status: "Click \"Open menu\" to open a native popup.".into(), } } } impl PopupExample { /// Closes the menu if it is open. Returns true if a menu was actually open. fn close_menu(&mut self, cx: &mut App) -> bool { match self.menu.take() { Some(menu) => menu .update(cx, |_, window, _| window.remove_window()) .is_ok(), None => false, } } fn toggle_menu(&mut self, parent: AnyWindowHandle, cx: &mut App) { if self.close_menu(cx) { return; } match open_menu(parent, cx) { Ok(menu) => { self.menu = Some(menu); self.status = "Menu open. Dismiss it by selecting an item, clicking elsewhere in \ this window, or clicking another application." .into(); } // A real application would fall back to an in-window popover here. Err(error) => { self.status = format!("Failed to open a native popup: {error}").into(); log::error!("failed to open popup: {error}"); } } } } impl Render for PopupExample { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let bullet = |text: &str| div().child(format!("• {text}")); div() .id("root") .size_full() .bg(rgb(0xf7f7f7)) .text_color(rgb(0x222222)) // Same-app clicks don't auto-dismiss a grabbing popup (see `PopupOptions::grab`). .on_mouse_down( MouseButton::Left, cx.listener(|this, _, _window, cx| { this.close_menu(cx); }), ) .child( div() .size_full() .p_5() .pt(px(76.)) .flex() .flex_col() .gap_3() .child(div().text_xl().child("Native popup test")) .child(div().text_sm().child( "WindowKind::AnchoredPopup opens a real, parent-anchored window that can \ extend past this window onto the screen. Only some platforms implement \ it so far.", )) .child( div() .flex() .flex_col() .gap_1() .text_sm() .text_color(rgb(0x555555)) .child(div().child("Verify:")) .child(bullet("The menu opens anchored below the button.")) .child(bullet( "The menu extends past the bottom edge of this window.", )) .child(bullet( "Near the bottom of the screen, the menu flips above the button.", )) .child(bullet("Clicking another application dismisses the menu.")) .child(bullet( "Selecting an item or clicking in this window dismisses it.", )), ) .child( div() .text_sm() .text_color(rgb(0x333333)) .child(self.status.clone()), ), ) .child( div() .absolute() .left(BUTTON_BOUNDS.origin.x) .top(BUTTON_BOUNDS.origin.y) .w(BUTTON_BOUNDS.size.width) .h(BUTTON_BOUNDS.size.height) .flex() .items_center() .justify_center() .bg(rgb(0xffffff)) .border_1() .border_color(rgb(0xd0d0d0)) .rounded_md() .cursor_pointer() .id("open-menu") .active(|this| this.bg(rgb(0xeeeeee))) .child("Open menu ▾") // Open on mouse-down, not on click, so the grab is taken while the button is still held. .on_mouse_down( MouseButton::Left, cx.listener(|this, _, window, cx| { // Don't let the window handler above close the menu we are opening. cx.stop_propagation(); this.toggle_menu(window.window_handle(), cx); }), ), ) } } fn open_menu(parent: AnyWindowHandle, cx: &mut App) -> anyhow::Result> { cx.open_window( WindowOptions { titlebar: None, // Sizes the popup. The platform decides the position, so the origin is ignored. window_bounds: Some(WindowBounds::Windowed(Bounds { origin: point(px(0.), px(0.)), size: POPUP_SIZE, })), kind: WindowKind::AnchoredPopup(PopupOptions { parent, anchor_rect: BUTTON_BOUNDS, // Anchor to the button's bottom-left and grow down-right so the menu drops beneath it. anchor: PopupAnchor::BottomLeft, gravity: PopupGravity::BottomRight, // Slide horizontally and flip vertically if the menu would leave the screen. constraint_adjustment: PopupConstraintAdjustment::SLIDE_X | PopupConstraintAdjustment::FLIP_Y, offset: point(px(0.), px(4.)), // Grab input so the compositor dismisses the popup on clicks into other applications. grab: true, }), ..Default::default() }, |_, cx| cx.new(|_| Menu), ) } fn run_example() { application().run(|cx: &mut App| { cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(Bounds { origin: point(px(100.), px(100.)), size: size(px(420.), px(300.)), })), ..Default::default() }, |_, cx| cx.new(|_| PopupExample::default()), ) .unwrap(); cx.activate(true); }); } #[cfg(not(target_family = "wasm"))] fn main() { run_example(); } #[cfg(target_family = "wasm")] #[wasm_bindgen::prelude::wasm_bindgen(start)] pub fn start() { gpui_platform::web_init(); run_example(); } ```
## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - N/A --- crates/gpui/Cargo.toml | 6 +- crates/gpui/src/platform.rs | 11 + crates/gpui/src/platform/popup.rs | 134 +++++++++ crates/gpui_linux/src/linux/wayland.rs | 1 + crates/gpui_linux/src/linux/wayland/client.rs | 65 +++- crates/gpui_linux/src/linux/wayland/popup.rs | 38 +++ crates/gpui_linux/src/linux/wayland/window.rs | 278 +++++++++++++++++- crates/gpui_linux/src/linux/x11/window.rs | 8 +- crates/gpui_macos/src/platform.rs | 9 +- crates/gpui_macos/src/window.rs | 8 +- crates/gpui_web/src/platform.rs | 8 +- crates/gpui_windows/src/window.rs | 6 + 12 files changed, 544 insertions(+), 28 deletions(-) create mode 100644 crates/gpui/src/platform/popup.rs create mode 100644 crates/gpui_linux/src/linux/wayland/popup.rs diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index bad1feabb3b..eab072e470b 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -29,9 +29,7 @@ test-support = [ bench = ["test-support", "dep:criterion", "dep:hdrhistogram"] inspector = ["gpui_macros/inspector"] leak-detection = ["backtrace"] -wayland = [ - "bitflags", -] +wayland = [] x11 = [ "scap?/x11", ] @@ -51,7 +49,7 @@ accesskit.workspace = true anyhow.workspace = true async-task = "4.7" backtrace = { workspace = true, optional = true } -bitflags = { workspace = true, optional = true } +bitflags.workspace = true collections.workspace = true criterion = { workspace = true, optional = true } diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 13fcbcee7a7..2a37d0b19b2 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -6,6 +6,9 @@ mod keystroke; #[expect(missing_docs)] pub mod layer_shell; +/// Types for configuring parent-anchored popup windows such as menus, dropdowns and tooltips. +pub mod popup; + #[cfg(any(test, feature = "bench"))] mod bench_dispatcher; @@ -1677,6 +1680,14 @@ pub enum WindowKind { /// use sparingly! PopUp, + /// A parent-anchored, platform-native popup window for menus, comboboxes, context menus and + /// tooltips. Unlike [`WindowKind::PopUp`], it is positioned relative to a parent window. + /// + /// The popup's size comes from [`WindowOptions::window_bounds`], whose origin is ignored. + /// See [`popup::PopupOptions`] for the placement options. Platforms without a native + /// implementation reject it with [`popup::PopupNotSupportedError`]. + AnchoredPopup(popup::PopupOptions), + /// A floating window that appears on top of its parent window Floating, diff --git a/crates/gpui/src/platform/popup.rs b/crates/gpui/src/platform/popup.rs new file mode 100644 index 00000000000..a1f8d7ea0d5 --- /dev/null +++ b/crates/gpui/src/platform/popup.rs @@ -0,0 +1,134 @@ +use bitflags::bitflags; +use thiserror::Error; + +use crate::{AnyWindowHandle, Bounds, Pixels, Point}; + +/// Options for a parent-anchored popup window such as a menu, dropdown, context menu or tooltip. +/// +/// A popup is placed relative to an anchor rectangle on its parent window rather than at an +/// absolute screen position. The platform resolves the final position, so this works both on +/// systems where the compositor owns window placement (Wayland) and on platforms with absolute +/// coordinates. +/// +/// The popup's size comes from [`WindowOptions::window_bounds`](crate::WindowOptions), whose +/// origin is ignored. All coordinates are in logical pixels. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PopupOptions { + /// The window the popup is anchored to. + pub parent: AnyWindowHandle, + + /// The rectangle the popup is positioned relative to, in the parent window's coordinate + /// space (the same space element bounds are in). For example, a dropdown menu uses the + /// bounds of the button that opened it. + pub anchor_rect: Bounds, + + /// Which point of [`Self::anchor_rect`] the popup is anchored to. + pub anchor: PopupAnchor, + + /// The direction in which the popup extends away from the anchor point. A dropdown that + /// drops below its button anchors to [`PopupAnchor::BottomLeft`] with a gravity of + /// [`PopupGravity::BottomRight`] so it grows down and to the right. + pub gravity: PopupGravity, + + /// How the platform may adjust the popup if the requested placement would put it off-screen. + pub constraint_adjustment: PopupConstraintAdjustment, + + /// An additional offset applied to the popup after anchoring. + pub offset: Point, + + /// Whether the popup should take an explicit input grab. + /// + /// Grabbing popups behave like menus: they take keyboard focus and are dismissed when the + /// user clicks outside of them or presses a dismissing key. Use it for menus and comboboxes, + /// not for tooltips or other passive popups. + /// + /// A grab must be requested while the triggering input is still active, in practice the + /// press of the mouse button that opens the popup. Open grabbing popups from a mouse-down + /// handler rather than a click handler, otherwise the grab is refused. + /// + /// Automatic dismissal only covers input aimed at other applications. A click elsewhere in + /// your own application still reaches it as usual, so closing the popup in that case is up + /// to you. Nested grabbing popups must be closed in the reverse order they were opened. + pub grab: bool, +} + +/// The point of the anchor rectangle that a popup is anchored to. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum PopupAnchor { + /// Anchor to the center of the anchor rectangle. + #[default] + Center, + /// Anchor to the center of the top edge. + Top, + /// Anchor to the center of the bottom edge. + Bottom, + /// Anchor to the center of the left edge. + Left, + /// Anchor to the center of the right edge. + Right, + /// Anchor to the top-left corner. + TopLeft, + /// Anchor to the bottom-left corner. + BottomLeft, + /// Anchor to the top-right corner. + TopRight, + /// Anchor to the bottom-right corner. + BottomRight, +} + +/// The direction in which a popup extends away from its anchor point. +/// +/// For instance, a gravity of [`PopupGravity::BottomRight`] places the popup below and to the +/// right of the anchor point. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum PopupGravity { + /// The popup is centered over the anchor point. + #[default] + Center, + /// The popup extends upwards from the anchor point. + Top, + /// The popup extends downwards from the anchor point. + Bottom, + /// The popup extends to the left of the anchor point. + Left, + /// The popup extends to the right of the anchor point. + Right, + /// The popup extends up and to the left of the anchor point. + TopLeft, + /// The popup extends down and to the left of the anchor point. + BottomLeft, + /// The popup extends up and to the right of the anchor point. + TopRight, + /// The popup extends down and to the right of the anchor point. + BottomRight, +} + +bitflags! { + /// How a popup may be adjusted by the platform if the requested placement would put it + /// off-screen. If no flags are set, the popup is placed exactly as requested and may be + /// clipped. + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] + pub struct PopupConstraintAdjustment: u32 { + /// The popup may be slid horizontally to stay on-screen. + const SLIDE_X = 1; + /// The popup may be slid vertically to stay on-screen. + const SLIDE_Y = 2; + /// The popup's anchor and gravity may be flipped horizontally to stay on-screen. + const FLIP_X = 4; + /// The popup's anchor and gravity may be flipped vertically to stay on-screen. + const FLIP_Y = 8; + /// The popup may be shrunk horizontally to stay on-screen. + const RESIZE_X = 16; + /// The popup may be shrunk vertically to stay on-screen. + const RESIZE_Y = 32; + } +} + +/// Returned when the current platform has no native popup implementation yet. +/// +/// Native popups are separate from gpui's in-window popovers, which are drawn as elements inside +/// an existing window. A caller that wants a popup on every platform should treat this error as +/// a cue to fall back to that in-window rendering. +#[derive(Debug, Error)] +#[error("popups are not supported on this platform")] +pub struct PopupNotSupportedError; diff --git a/crates/gpui_linux/src/linux/wayland.rs b/crates/gpui_linux/src/linux/wayland.rs index 3e90688d1bd..cbc962bcffe 100644 --- a/crates/gpui_linux/src/linux/wayland.rs +++ b/crates/gpui_linux/src/linux/wayland.rs @@ -2,6 +2,7 @@ mod client; mod clipboard; mod cursor; mod display; +mod popup; mod serial; mod window; diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index 256875bed1e..9522ebb8f0d 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -57,7 +57,9 @@ use wayland_protocols::xdg::activation::v1::client::{xdg_activation_token_v1, xd use wayland_protocols::xdg::decoration::zv1::client::{ zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1, }; -use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base}; +use wayland_protocols::xdg::shell::client::{ + xdg_popup, xdg_positioner, xdg_surface, xdg_toplevel, xdg_wm_base, +}; use wayland_protocols::xdg::system_bell::v1::client::xdg_system_bell_v1; use wayland_protocols::{ wp::cursor_shape::v1::client::{wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1}, @@ -96,7 +98,7 @@ use gpui::{ ForegroundExecutor, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels, PlatformDisplay, PlatformInput, PlatformKeyboardLayout, PlatformWindow, Point, - ScrollDelta, ScrollWheelEvent, SharedString, Size, TouchPhase, WindowButtonLayout, + ScrollDelta, ScrollWheelEvent, SharedString, Size, TouchPhase, WindowButtonLayout, WindowKind, WindowParams, point, profiler, px, size, }; use gpui_wgpu::{CompositorGpuHint, GpuContext}; @@ -846,7 +848,29 @@ impl LinuxClient for WaylandClient { ) -> anyhow::Result> { let mut state = self.0.borrow_mut(); - let parent = state.keyboard_focused_window.clone(); + // Popups name their parent explicitly. Other kinds are parented to the focused window. + let (parent, popup_grab) = match ¶ms.kind { + WindowKind::AnchoredPopup(options) => { + let parent = state + .windows + .values() + .find(|window| window.handle() == options.parent) + .cloned() + .ok_or_else(|| anyhow::anyhow!("popup parent window not found"))?; + // A popup grab must reference a press event or the compositor declines it and + // immediately dismisses the popup, so use the most recent press serial, or no + // grab before any press. + let popup_grab = options.grab.then(|| { + let serial = state + .serial_tracker + .get(SerialKind::MousePress) + .max(state.serial_tracker.get(SerialKind::KeyPress)); + (serial != 0).then(|| (serial, state.wl_seat.clone())) + }); + (Some(parent), popup_grab.flatten()) + } + _ => (state.keyboard_focused_window.clone(), None), + }; let target_output = params.display_id.and_then(|display_id| { let target_protocol_id: u64 = display_id.into(); @@ -859,6 +883,7 @@ impl LinuxClient for WaylandClient { let appearance = state.common.appearance; let compositor_gpu = state.compositor_gpu.take(); + let (window, surface_id) = WaylandWindow::new( handle, state.globals.clone(), @@ -868,8 +893,10 @@ impl LinuxClient for WaylandClient { params, appearance, parent, + popup_grab, target_output, )?; + if window.0.toplevel().is_some() { state.consume_startup_activation_token(&window.0.surface()); } @@ -1196,6 +1223,7 @@ delegate_noop!(WaylandClientStatePtr: ignore wl_region::WlRegion); delegate_noop!(WaylandClientStatePtr: ignore wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1); delegate_noop!(WaylandClientStatePtr: ignore zxdg_decoration_manager_v1::ZxdgDecorationManagerV1); delegate_noop!(WaylandClientStatePtr: ignore zwlr_layer_shell_v1::ZwlrLayerShellV1); +delegate_noop!(WaylandClientStatePtr: ignore xdg_positioner::XdgPositioner); delegate_noop!(WaylandClientStatePtr: ignore org_kde_kwin_blur_manager::OrgKdeKwinBlurManager); delegate_noop!(WaylandClientStatePtr: ignore zwp_text_input_manager_v3::ZwpTextInputManagerV3); delegate_noop!(WaylandClientStatePtr: ignore org_kde_kwin_blur::OrgKdeKwinBlur); @@ -1359,6 +1387,31 @@ impl Dispatch for WaylandCl drop(state); let should_close = window.handle_layersurface_event(event); + if should_close { + // Close logic will be handled in drop_window() + window.close(); + } + } +} + +impl Dispatch for WaylandClientStatePtr { + fn event( + this: &mut Self, + _: &xdg_popup::XdgPopup, + event: ::Event, + surface_id: &ObjectId, + _: &Connection, + _: &QueueHandle, + ) { + let client = this.get_client(); + let mut state = client.borrow_mut(); + let Some(window) = get_window(&mut state, surface_id) else { + return; + }; + + drop(state); + let should_close = window.handle_popup_event(event); + if should_close { // The close logic will be handled in drop_window() window.close(); @@ -1934,7 +1987,11 @@ impl Dispatch for WaylandClientStatePtr { state: WEnum::Value(button_state), .. } => { - state.serial_tracker.update(SerialKind::MousePress, serial); + // Record presses only. Requests referencing this serial (popup grabs, + // interactive moves) are declined when given a release serial. + if button_state == wl_pointer::ButtonState::Pressed { + state.serial_tracker.update(SerialKind::MousePress, serial); + } let button = linux_button_to_gpui(button); let Some(button) = button else { return }; if state.mouse_focused_window.is_none() { diff --git a/crates/gpui_linux/src/linux/wayland/popup.rs b/crates/gpui_linux/src/linux/wayland/popup.rs new file mode 100644 index 00000000000..4a15e78211b --- /dev/null +++ b/crates/gpui_linux/src/linux/wayland/popup.rs @@ -0,0 +1,38 @@ +pub use gpui::popup::*; + +use wayland_protocols::xdg::shell::client::xdg_positioner; + +pub(crate) fn wayland_anchor(anchor: PopupAnchor) -> xdg_positioner::Anchor { + match anchor { + PopupAnchor::Center => xdg_positioner::Anchor::None, + PopupAnchor::Top => xdg_positioner::Anchor::Top, + PopupAnchor::Bottom => xdg_positioner::Anchor::Bottom, + PopupAnchor::Left => xdg_positioner::Anchor::Left, + PopupAnchor::Right => xdg_positioner::Anchor::Right, + PopupAnchor::TopLeft => xdg_positioner::Anchor::TopLeft, + PopupAnchor::BottomLeft => xdg_positioner::Anchor::BottomLeft, + PopupAnchor::TopRight => xdg_positioner::Anchor::TopRight, + PopupAnchor::BottomRight => xdg_positioner::Anchor::BottomRight, + } +} + +pub(crate) fn wayland_gravity(gravity: PopupGravity) -> xdg_positioner::Gravity { + match gravity { + PopupGravity::Center => xdg_positioner::Gravity::None, + PopupGravity::Top => xdg_positioner::Gravity::Top, + PopupGravity::Bottom => xdg_positioner::Gravity::Bottom, + PopupGravity::Left => xdg_positioner::Gravity::Left, + PopupGravity::Right => xdg_positioner::Gravity::Right, + PopupGravity::TopLeft => xdg_positioner::Gravity::TopLeft, + PopupGravity::BottomLeft => xdg_positioner::Gravity::BottomLeft, + PopupGravity::TopRight => xdg_positioner::Gravity::TopRight, + PopupGravity::BottomRight => xdg_positioner::Gravity::BottomRight, + } +} + +pub(crate) fn wayland_constraint_adjustment( + adjustment: PopupConstraintAdjustment, +) -> xdg_positioner::ConstraintAdjustment { + // The flag values match the protocol bitfield, so the bits map across directly. + xdg_positioner::ConstraintAdjustment::from_bits_truncate(adjustment.bits()) +} diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index 731569a025f..675602082bb 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -1,12 +1,12 @@ use std::{ - cell::{Ref, RefCell, RefMut}, + cell::{Cell, Ref, RefCell, RefMut}, ffi::c_void, ptr::NonNull, rc::Rc, sync::Arc, }; -use collections::{FxHashSet, HashMap}; +use collections::{FxHashMap, HashMap}; use futures::channel::oneshot::Receiver; use raw_window_handle as rwh; @@ -14,10 +14,12 @@ use wayland_backend::client::ObjectId; use wayland_client::WEnum; use wayland_client::{ Proxy, - protocol::{wl_output, wl_surface}, + protocol::{wl_output, wl_seat, wl_surface}, }; use wayland_protocols::wp::viewporter::client::wp_viewport; use wayland_protocols::xdg::decoration::zv1::client::zxdg_toplevel_decoration_v1; +use wayland_protocols::xdg::shell::client::xdg_popup; +use wayland_protocols::xdg::shell::client::xdg_positioner; use wayland_protocols::xdg::shell::client::xdg_surface; use wayland_protocols::xdg::shell::client::xdg_toplevel::{self}; use wayland_protocols::{ @@ -34,8 +36,8 @@ use gpui::{ PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, Scene, Size, Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, - WindowDecorations, WindowKind, WindowParams, layer_shell::LayerShellNotSupportedError, px, - size, + WindowDecorations, WindowKind, WindowParams, layer_shell::LayerShellNotSupportedError, + popup::PopupOptions, px, size, }; use gpui_wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig, wgpu}; @@ -93,7 +95,9 @@ pub struct WaylandWindowState { surface_state: WaylandSurfaceState, acknowledged_first_configure: bool, parent: Option, - children: FxHashSet, + /// Child surfaces mapped to whether they block this window's input (dialogs + /// block, popups don't). Children are closed before this window closes. + children: FxHashMap, pub surface: wl_surface::WlSurface, app_id: Option, appearance: WindowAppearance, @@ -129,6 +133,7 @@ pub struct WaylandWindowState { pub enum WaylandSurfaceState { Xdg(WaylandXdgSurfaceState), LayerShell(WaylandLayerSurfaceState), + Popup(WaylandPopupSurfaceState), } impl WaylandSurfaceState { @@ -137,6 +142,7 @@ impl WaylandSurfaceState { globals: &Globals, params: &WindowParams, parent: Option, + popup_grab: Option<(u32, wl_seat::WlSeat)>, target_output: Option, ) -> anyhow::Result { // For layer_shell windows, create a layer surface instead of an xdg surface @@ -186,6 +192,54 @@ impl WaylandSurfaceState { })); } + if let WindowKind::AnchoredPopup(options) = ¶ms.kind { + let Some(parent) = parent.as_ref() else { + return Err(anyhow::anyhow!("popup parent window not found")); + }; + + let positioner = build_popup_positioner( + globals, + options, + params.bounds.size, + parent.window_geometry(), + ); + + let xdg_surface = globals + .wm_base + .get_xdg_surface(&surface, &globals.qh, surface.id()); + + // A layer-shell parent takes a null xdg parent and is attached via the layer + // surface. Every other surface kind has an xdg_surface to parent to directly. + let xdg_popup = if let Some(parent_layer_surface) = parent.layer_surface() { + let xdg_popup = xdg_surface.get_popup(None, &positioner, &globals.qh, surface.id()); + parent_layer_surface.get_popup(&xdg_popup); + xdg_popup + } else { + xdg_surface.get_popup( + parent.xdg_surface().as_ref(), + &positioner, + &globals.qh, + surface.id(), + ) + }; + positioner.destroy(); + + if let Some((serial, seat)) = popup_grab { + xdg_popup.grab(&seat, serial); + } + + // Non-blocking: the parent keeps its input so it can dismiss the popup on + // clicks in its own window. + parent.add_child(surface.id(), false); + + return Ok(WaylandSurfaceState::Popup(WaylandPopupSurfaceState { + xdg_surface, + xdg_popup, + options: options.clone(), + next_reposition_token: Cell::new(0), + })); + } + // All other WindowKinds result in a regular xdg surface let xdg_surface = globals .wm_base @@ -206,7 +260,7 @@ impl WaylandSurfaceState { }); if let Some(parent) = parent.as_ref() { - parent.add_child(surface.id()); + parent.add_child(surface.id(), true); } dialog @@ -246,6 +300,65 @@ pub struct WaylandLayerSurfaceState { layer_surface: zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, } +pub struct WaylandPopupSurfaceState { + xdg_surface: xdg_surface::XdgSurface, + xdg_popup: xdg_popup::XdgPopup, + // Kept so the popup can be re-anchored via `xdg_popup.reposition` when resized. + options: PopupOptions, + next_reposition_token: Cell, +} + +fn build_popup_positioner( + globals: &Globals, + options: &PopupOptions, + size: Size, + parent_geometry: Bounds, +) -> xdg_positioner::XdgPositioner { + let positioner = globals.wm_base.create_positioner(&globals.qh, ()); + // A zero or negative size is a protocol error. + positioner.set_size( + f32::from(size.width).max(1.0) as i32, + f32::from(size.height).max(1.0) as i32, + ); + + // The protocol wants the anchor rect relative to the parent's window geometry, while + // `options.anchor_rect` is in gpui window coordinates (surface-local). A rect extending + // outside the geometry or with a zero size is a protocol error, so translate, then clamp + // to at least one pixel inside the geometry, pulling the origin inward at the edges. + let anchor_rect = Bounds { + origin: options.anchor_rect.origin - parent_geometry.origin, + size: options.anchor_rect.size, + }; + let one = Point::new(px(1.0), px(1.0)); + let geometry_bottom_right: Point = parent_geometry.size.into(); + let top_left = anchor_rect + .origin + .min(&(geometry_bottom_right - one)) + .max(&Point::default()); + let bottom_right = anchor_rect + .bottom_right() + .min(&geometry_bottom_right) + .max(&(top_left + one)); + let anchor_rect = Bounds::from_corners(top_left, bottom_right); + positioner.set_anchor_rect( + f32::from(anchor_rect.origin.x) as i32, + f32::from(anchor_rect.origin.y) as i32, + f32::from(anchor_rect.size.width) as i32, + f32::from(anchor_rect.size.height) as i32, + ); + + positioner.set_anchor(super::popup::wayland_anchor(options.anchor)); + positioner.set_gravity(super::popup::wayland_gravity(options.gravity)); + positioner.set_constraint_adjustment(super::popup::wayland_constraint_adjustment( + options.constraint_adjustment, + )); + positioner.set_offset( + f32::from(options.offset.x) as i32, + f32::from(options.offset.y) as i32, + ); + positioner +} + impl WaylandSurfaceState { fn ack_configure(&self, serial: u32) { match self { @@ -255,6 +368,9 @@ impl WaylandSurfaceState { WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface, .. }) => { layer_surface.ack_configure(serial); } + WaylandSurfaceState::Popup(WaylandPopupSurfaceState { xdg_surface, .. }) => { + xdg_surface.ack_configure(serial); + } } } @@ -274,6 +390,28 @@ impl WaylandSurfaceState { } } + fn xdg_surface(&self) -> Option<&xdg_surface::XdgSurface> { + match self { + WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { xdg_surface, .. }) => { + Some(xdg_surface) + } + WaylandSurfaceState::Popup(WaylandPopupSurfaceState { xdg_surface, .. }) => { + Some(xdg_surface) + } + WaylandSurfaceState::LayerShell(_) => None, + } + } + + fn layer_surface(&self) -> Option<&zwlr_layer_surface_v1::ZwlrLayerSurfaceV1> { + if let WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface, .. }) = + self + { + Some(layer_surface) + } else { + None + } + } + fn set_geometry(&self, x: i32, y: i32, width: i32, height: i32) { match self { WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { xdg_surface, .. }) => { @@ -283,6 +421,34 @@ impl WaylandSurfaceState { // cannot set window position of a layer surface layer_surface.set_size(width as u32, height as u32); } + WaylandSurfaceState::Popup(WaylandPopupSurfaceState { xdg_surface, .. }) => { + xdg_surface.set_window_geometry(x, y, width, height); + } + } + } + + // Re-anchors a mapped popup at a new size via `xdg_popup.reposition`. Repositioning an + // unmapped popup (before the first configure) is a protocol error. + fn reposition_popup( + &self, + globals: &Globals, + size: Size, + parent_geometry: Bounds, + ) { + if let WaylandSurfaceState::Popup(WaylandPopupSurfaceState { + xdg_popup, + options, + next_reposition_token, + .. + }) = self + && xdg_popup.version() >= xdg_popup::REQ_REPOSITION_SINCE + { + let token = next_reposition_token.get(); + next_reposition_token.set(token.wrapping_add(1)); + + let positioner = build_popup_positioner(globals, options, size, parent_geometry); + xdg_popup.reposition(&positioner, token); + positioner.destroy(); } } @@ -307,6 +473,15 @@ impl WaylandSurfaceState { WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface }) => { layer_surface.destroy(); } + WaylandSurfaceState::Popup(WaylandPopupSurfaceState { + xdg_surface, + xdg_popup, + .. + }) => { + // Role object before its xdg_surface, as with the toplevel above. + xdg_popup.destroy(); + xdg_surface.destroy(); + } } } } @@ -374,7 +549,7 @@ impl WaylandWindowState { surface_state, acknowledged_first_configure: false, parent, - children: FxHashSet::default(), + children: FxHashMap::default(), surface, app_id: options.app_id, blur: None, @@ -523,11 +698,18 @@ impl WaylandWindow { params: WindowParams, appearance: WindowAppearance, parent: Option, + popup_grab: Option<(u32, wl_seat::WlSeat)>, target_output: Option, ) -> anyhow::Result<(Self, ObjectId)> { let surface = globals.compositor.create_surface(&globals.qh, ()); - let surface_state = - WaylandSurfaceState::new(&surface, &globals, ¶ms, parent.clone(), target_output)?; + let surface_state = WaylandSurfaceState::new( + &surface, + &globals, + ¶ms, + parent.clone(), + popup_grab, + target_output, + )?; if let Some(fractional_scale_manager) = globals.fractional_scale_manager.as_ref() { fractional_scale_manager.get_fractional_scale(&surface, &globals.qh, surface.id()); @@ -575,18 +757,39 @@ impl WaylandWindowStatePtr { self.state.borrow().surface_state.toplevel().cloned() } + /// The `xdg_surface` backing this window, if it has one. Used to anchor child popups. + pub fn xdg_surface(&self) -> Option { + self.state.borrow().surface_state.xdg_surface().cloned() + } + + /// The layer-shell surface backing this window, if it is one. Used to anchor child popups. + pub fn layer_surface(&self) -> Option { + self.state.borrow().surface_state.layer_surface().cloned() + } + + /// This window's xdg window geometry in surface-local coordinates. Child popup anchor + /// rectangles are relative to it, while gpui coordinates are surface-local. + pub fn window_geometry(&self) -> Bounds { + let state = self.state.borrow(); + inset_by_tiling( + state.bounds.map_origin(|_| px(0.0)), + state.inset(), + state.tiling, + ) + } + pub fn ptr_eq(&self, other: &Self) -> bool { Rc::ptr_eq(&self.state, &other.state) } - pub fn add_child(&self, child: ObjectId) { + pub fn add_child(&self, child: ObjectId, blocking: bool) { let mut state = self.state.borrow_mut(); - state.children.insert(child); + state.children.insert(child, blocking); } pub fn is_blocked(&self) -> bool { let state = self.state.borrow(); - !state.children.is_empty() + state.children.values().any(|&blocking| blocking) } pub fn frame(&self) { @@ -889,6 +1092,35 @@ impl WaylandWindowStatePtr { } } + // Returns `true` if the popup should be closed. + pub fn handle_popup_event(&self, event: xdg_popup::Event) -> bool { + match event { + // Only the size is needed, the position is the compositor's. The following + // xdg_surface.configure applies the change. + xdg_popup::Event::Configure { width, height, .. } => { + let size = if width <= 0 || height <= 0 { + None + } else { + Some(size(px(width as f32), px(height as f32))) + }; + + self.state.borrow_mut().in_progress_configure = Some(InProgressConfigure { + size, + fullscreen: false, + maximized: false, + resizing: false, + tiling: Tiling::default(), + }); + + false + } + xdg_popup::Event::PopupDone => true, + // Precedes the reposition's Configure, which does the work. The token is not needed. + xdg_popup::Event::Repositioned { .. } => false, + _ => false, + } + } + #[allow(clippy::mutable_key_type)] pub fn handle_surface_event( &self, @@ -1025,8 +1257,7 @@ impl WaylandWindowStatePtr { pub fn close(&self) { let state = self.state.borrow(); let client = state.client.get_client(); - #[allow(clippy::mutable_key_type)] - let children = state.children.clone(); + let children = state.children.keys().cloned().collect::>(); drop(state); for child in children { @@ -1192,6 +1423,23 @@ impl PlatformWindow for WaylandWindow { let state = self.borrow(); let state_ptr = self.0.clone(); + // A popup's placement is the compositor's, so a resize re-runs the positioner and the + // configure reply drives the buffer resize. Before the first configure the popup is + // unmapped and cannot reposition, but the initial positioner already carries the size. + if matches!(state.surface_state, WaylandSurfaceState::Popup(_)) { + if state.acknowledged_first_configure { + let parent_geometry = state + .parent + .as_ref() + .map(|parent| parent.window_geometry()) + .unwrap_or_default(); + state + .surface_state + .reposition_popup(&state.globals, size, parent_geometry); + } + return; + } + // Keep window geometry consistent with configure handling. On Wayland, window geometry is // surface-local: resizing should not attempt to translate the window; the compositor // controls placement. We also account for client-side decoration insets and tiling. diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index d2edb72408b..a459ed03b5d 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -7,7 +7,7 @@ use gpui::{ Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, ScaledPixels, Scene, Size, Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, - WindowDecorations, WindowKind, WindowParams, px, + WindowDecorations, WindowKind, WindowParams, popup::PopupNotSupportedError, px, }; use gpui_wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig}; @@ -428,6 +428,12 @@ impl X11WindowState { supports_xinput_gestures: bool, is_bgr: bool, ) -> anyhow::Result { + // Native popups are not implemented on X11 yet. Rejecting lets callers fall back to + // gpui's in-window popovers. + if let WindowKind::AnchoredPopup(_) = params.kind { + return Err(PopupNotSupportedError.into()); + } + let x_screen_index = params .display_id .map_or(x_main_screen_index, |did| u64::from(did) as usize); diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index 8266cade4d2..eed9e90bcd3 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -31,7 +31,8 @@ use gpui::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, ForegroundExecutor, KeyContext, Keymap, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, - PlatformWindow, Result, SystemMenuType, Task, ThermalState, WindowAppearance, WindowParams, + PlatformWindow, Result, SystemMenuType, Task, ThermalState, WindowAppearance, WindowKind, + WindowParams, popup::PopupNotSupportedError, }; use gpui_util::{ResultExt, new_std_command}; use itertools::Itertools; @@ -640,6 +641,12 @@ impl Platform for MacPlatform { handle: AnyWindowHandle, options: WindowParams, ) -> Result> { + // Native popups are not implemented on macOS yet. Rejecting lets callers fall back to + // gpui's in-window popovers. + if let WindowKind::AnchoredPopup(_) = options.kind { + return Err(PopupNotSupportedError.into()); + } + let (cursor_visible, foreground_executor, background_executor, renderer_context) = { let guard = self.0.lock(); ( diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index d3d87608253..6221b141610 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -795,7 +795,9 @@ impl MacWindow { WindowKind::Normal => { msg_send![WINDOW_CLASS, alloc] } - WindowKind::PopUp => { + // `AnchoredPopup` is rejected in `MacPlatform::open_window`, grouped here only + // for exhaustiveness. + WindowKind::PopUp | WindowKind::AnchoredPopup(_) => { style_mask |= NSWindowStyleMaskNonactivatingPanel; msg_send![PANEL_CLASS, alloc] } @@ -988,7 +990,9 @@ impl MacWindow { let _: () = msg_send![native_window, setTabbingIdentifier:nil]; } } - WindowKind::PopUp => { + // `AnchoredPopup` is rejected in `MacPlatform::open_window`, grouped here only + // for exhaustiveness. + WindowKind::PopUp | WindowKind::AnchoredPopup(_) => { // Use a tracking area to allow receiving MouseMoved events even when // the window or application aren't active, which is often the case // e.g. for notification windows. diff --git a/crates/gpui_web/src/platform.rs b/crates/gpui_web/src/platform.rs index fecc39f368d..c39723ea5b2 100644 --- a/crates/gpui_web/src/platform.rs +++ b/crates/gpui_web/src/platform.rs @@ -8,7 +8,7 @@ use gpui::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DummyKeyboardMapper, ForegroundExecutor, Keymap, Menu, MenuItem, PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PlatformWindow, Task, - ThermalState, WindowAppearance, WindowParams, + ThermalState, WindowAppearance, WindowKind, WindowParams, popup::PopupNotSupportedError, }; use gpui_wgpu::WgpuContext; use std::{ @@ -166,6 +166,12 @@ impl Platform for WebPlatform { handle: AnyWindowHandle, params: WindowParams, ) -> anyhow::Result> { + // Native popups are not implemented on the web yet. Rejecting lets callers fall back to + // gpui's in-window popovers. + if let WindowKind::AnchoredPopup(_) = params.kind { + return Err(PopupNotSupportedError.into()); + } + let context_ref = self.wgpu_context.borrow(); let context = context_ref.as_ref().ok_or_else(|| { anyhow::anyhow!("WebGPU context not initialized. Was Platform::run() called?") diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 547ee7a9dde..76075a66b1c 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -411,6 +411,12 @@ impl WindowsWindow { params: WindowParams, creation_info: WindowCreationInfo, ) -> Result { + // Native popups are not implemented on Windows yet. Rejecting lets callers fall back to + // gpui's in-window popovers. + if let WindowKind::AnchoredPopup(_) = params.kind { + return Err(popup::PopupNotSupportedError.into()); + } + let WindowCreationInfo { icon, executor, From 23c0080d1dd5e8e550ede91c0d573658e5aabe76 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 8 Jul 2026 15:27:01 -0400 Subject: [PATCH 155/197] gpui_windows: Check composition attribute lookup (#60122) Checks that SetWindowCompositionAttribute was found before transmuting and calling the function pointer, making the composition setup a no-op when the undocumented export is unavailable. Release Notes: - N/A --- crates/gpui_windows/src/window.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 76075a66b1c..5a4129b493c 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -1545,8 +1545,12 @@ fn set_window_composition_attribute(hwnd: HWND, color: Option, state: u32 .log_err() { let func_name = PCSTR::from_raw(c"SetWindowCompositionAttribute".as_ptr() as *const u8); + let Some(raw_set_window_composition_attribute) = GetProcAddress(user32, func_name) + else { + return; + }; let set_window_composition_attribute: SetWindowCompositionAttributeType = - std::mem::transmute(GetProcAddress(user32, func_name)); + std::mem::transmute(raw_set_window_composition_attribute); let mut color = color.unwrap_or_default(); let is_acrylic = state == 4; if is_acrylic && color.3 == 0 { From 664b7ecb40b83fa728bc40ef8f0385e35ea01287 Mon Sep 17 00:00:00 2001 From: Philipp Schaffrath Date: Wed, 8 Jul 2026 22:27:47 +0200 Subject: [PATCH 156/197] gpui: Fix hover state not clearing when mouse leaves window (#60275) # Objective Fix two gaps in element hover tracking at window boundaries. Hover was only re-evaluated on `MouseMove`, so when the pointer left the window no event fired `on_hover(false)` and the element stayed hovered. Symmetrically on Wayland, no `Motion` follows `Enter` until the pointer moves again, so hover was not established at the entry pixel. Both cases are easy to miss since most hover-styled elements don't sit flush against the window edge, but they surfaced while implementing layer_shell popups with input_regions, which should close when stop hovering. ## Solution The hover compare-and-fire logic in `div` is refactored into a shared `update_hover` closure, and a second listener on `MouseExitEvent` clears hover when the pointer leaves the window. It clears unconditionally because `MouseExited` doesn't update the tracked mouse position, so a hit test during that dispatch would still report the element as hovered. On Wayland, a `MouseMove` is synthesized at the entry position on `wl_pointer.enter`, mirroring the `MouseExited` already dispatched on `Leave`. ## Testing Tested manually on Wayland/Linux: hover on a window-edge element clears when the pointer leaves the window, and hover is established immediately when the pointer enters a surface with an element under the entry pixel. Not tested on other platforms. The `div` change relies on each platform's existing `MouseExited` dispatch: macOS and X11 emit it, so they get the exit fix too. Windows never dispatches `MouseExited` (`WM_MOUSELEAVE` only flips the window-level hover flag), so the stuck-hover case might remain there, unchanged from before. Before: https://github.com/user-attachments/assets/6af83bb3-de9d-40e2-a64c-bdefc98fc96d After: https://github.com/user-attachments/assets/721a9b5c-108a-499f-867e-da111836a34a Here is an example application to test this: ```rs #![cfg_attr(target_family = "wasm", no_main)] use gpui::{ App, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size, }; use gpui_platform::application; struct HoverExit { hovered: bool, } impl Render for HoverExit { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { // Fills the whole window so its edge is the window edge: moving the mouse // out of the window is what exercises the MouseExited path. div() .id("hover-exit") .size_full() .flex() .justify_center() .items_center() .text_xl() .text_color(rgb(0xffffff)) .bg(if self.hovered { rgb(0x585f58) } else { rgb(0x505050) }) .child(if self.hovered { "HOVERED" } else { "not hovered" }) .on_hover(cx.listener(|this, hovered, _, cx| { this.hovered = *hovered; cx.notify(); })) } } fn run_example() { application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(240.), px(160.0)), cx); cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), app_id: Some("gpui-hover-exit".to_string()), ..Default::default() }, |_, cx| cx.new(|_| HoverExit { hovered: false }), ) .unwrap(); cx.activate(true); }); } #[cfg(not(target_family = "wasm"))] fn main() { run_example(); } #[cfg(target_family = "wasm")] #[wasm_bindgen::prelude::wasm_bindgen(start)] pub fn start() { gpui_platform::web_init(); run_example(); } ``` ## 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 adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - Fixed element hover state not clearing when the mouse leaves the window --- crates/gpui/src/elements/div.rs | 34 +++++++++++++------ crates/gpui_linux/src/linux/wayland/client.rs | 11 +++++- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index ebbce8f84c9..04692c86a55 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -2860,7 +2860,6 @@ impl Interactivity { } if let Some(hover_listener) = self.hover_listener.take() { - let hitbox = hitbox.clone(); let was_hovered = element_state .hover_listener_state .get_or_insert_with(Default::default) @@ -2869,22 +2868,35 @@ impl Interactivity { .pending_mouse_down .get_or_insert_with(Default::default) .clone(); - - window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, cx| { - if phase != DispatchPhase::Bubble { - return; - } - let is_hovered = has_mouse_down.borrow().is_none() - && !cx.has_active_drag() - && hitbox.is_hovered(window); + let hover_listener = Rc::new(hover_listener); + let update_hover = move |is_hovered: bool, window: &mut Window, cx: &mut App| { let mut was_hovered = was_hovered.borrow_mut(); - if is_hovered != *was_hovered { *was_hovered = is_hovered; drop(was_hovered); - hover_listener(&is_hovered, window, cx); } + }; + + window.on_mouse_event({ + let update_hover = update_hover.clone(); + let hitbox = hitbox.clone(); + move |_: &MouseMoveEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble { + let is_hovered = has_mouse_down.borrow().is_none() + && !cx.has_active_drag() + && hitbox.is_hovered(window); + update_hover(is_hovered, window, cx); + } + } + }); + + // The pointer can leave the window without a final MouseMove, so also + // clear hover on MouseExited. + window.on_mouse_event(move |_: &MouseExitEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble { + update_hover(false, window, cx); + } }); } diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index 9522ebb8f0d..a4cc9fde9f1 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -1884,8 +1884,9 @@ impl Dispatch for WaylandClientStatePtr { surface_y, .. } => { + let position = point(px(surface_x as f32), px(surface_y as f32)); state.serial_tracker.update(SerialKind::MouseEnter, serial); - state.mouse_location = Some(point(px(surface_x as f32), px(surface_y as f32))); + state.mouse_location = Some(position); state.button_pressed = None; if let Some(window) = get_window(&mut state, &surface.id()) { @@ -1908,8 +1909,16 @@ impl Dispatch for WaylandClientStatePtr { ); } } + let modifiers = state.modifiers; drop(state); window.set_hovered(true); + // No Motion follows Enter unless the pointer keeps moving, so synthesize + // a MouseMove to establish hover at the entry position. + window.handle_input(PlatformInput::MouseMove(MouseMoveEvent { + position, + pressed_button: None, + modifiers, + })); } } wl_pointer::Event::Leave { .. } => { From dd68454633ac9fea8b42115ae09568e7395831e2 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 8 Jul 2026 16:37:57 -0400 Subject: [PATCH 157/197] Update wayland-backend to fix Wayland file dialog crash (#60621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Wayland, closing a window in the brief gap between requesting a portal file dialog and ashpd exporting the window's surface (used to parent the dialog) crashed Zed with `Unknown opcode 0 for object @0` ([ZED-9KB](https://zed-dev.sentry.io/issues/7568720776/)). When the export request fails because the surface is already dead, wayland-scanner's generated code silently returns an inert proxy, and ashpd's `Drop` impl later sends `destroy` on it — which wayland-backend 0.3.11 answers with a panic, because it looks up the request opcode before checking whether the object is null. wayland-backend 0.3.15 fixes this ([Smithay/wayland-rs#890](https://github.com/Smithay/wayland-rs/issues/890)) by returning an error instead, which the generated destructor discards, so the drop becomes a harmless no-op. This bumps the lockfile to 0.3.15 and raises the version floor in `gpui_linux` so the fix can't silently regress via a fresh lockfile. Closes FR-100 Release Notes: - Fixed a crash on Linux (Wayland) when a window was closed just as a file dialog was being opened. --- Cargo.lock | 12 ++++++------ crates/gpui_linux/Cargo.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3f71a7000d7..f65225de1bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5344,9 +5344,9 @@ dependencies = [ [[package]] name = "dlib" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ "libloading", ] @@ -20843,9 +20843,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.11" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" dependencies = [ "cc", "downcast-rs", @@ -20929,9 +20929,9 @@ dependencies = [ [[package]] name = "wayland-sys" -version = "0.31.7" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ "dlib", "log", diff --git a/crates/gpui_linux/Cargo.toml b/crates/gpui_linux/Cargo.toml index a1e0531f3d5..262da8d4dff 100644 --- a/crates/gpui_linux/Cargo.toml +++ b/crates/gpui_linux/Cargo.toml @@ -96,7 +96,7 @@ scap = { workspace = true, optional = true } # Wayland calloop-wayland-source = { version = "0.4.1", optional = true } -wayland-backend = { version = "0.3.3", features = [ +wayland-backend = { version = "0.3.15", features = [ "client_system", "dlopen", ], optional = true } From 10504e3ce18bb1d2444baf1047fa4ea9835ff1cc Mon Sep 17 00:00:00 2001 From: morgankrey Date: Wed, 8 Jul 2026 15:54:51 -0500 Subject: [PATCH 158/197] Improve docs AI readiness (#59577) Context This PR makes the Zed docs easier for AI tools, search crawlers, and users to consume without changing the visible docs content. The current production docs are primarily optimized for browser navigation. They do not expose first-class Markdown URLs, an `llms.txt` index, page-level copy affordances, or machine-readable freshness metadata that let users and agents grab clean, current page content. Changes - Generate Markdown copies for docs pages during the mdBook postprocess step, including `/docs/index.md` as an alias for Getting Started. - Generate `/docs/llms.txt` from the mdBook chapter list, grouped by `SUMMARY.md` sections and annotated with page frontmatter descriptions. - Generate `/docs/sitemap.xml` with `` values for every docs page. - Emit machine-readable freshness metadata in HTML via `last-modified` and `article:modified_time` meta tags. - Generate Cloudflare Pages `_redirects` for `.html`, extensionless, and `.md` redirect variants, with channel-aware docs destinations. - Add discovery hints for agents and crawlers: `rel="llms.txt"`, `rel="alternate" type="text/markdown"`, and a short generated `llms.txt` directive in copied Markdown pages. - Update the docs proxy so `Accept: text/markdown`, `/docs.md`, and direct `.md` requests can resolve to the generated Markdown artifacts. - Move primary docs content earlier in the HTML source while preserving the visible layout, so crawlers and agent scorers encounter the article before sidebar chrome. - Move the existing copy-as-Markdown control from the top navigation into the page-title row, using the generated Markdown alternate link as the source of truth. - Split AI-discovery artifact generation out of `docs_preprocessor/src/main.rs` into a focused module. Best Practices Adopted - Use `llms.txt` as a concise navigation index, not a dump of full page content. - Link to absolute, canonical Markdown URLs from `llms.txt`. - Preserve the docs hierarchy in `llms.txt` instead of emitting a flat sitemap-like list. - Include short per-link descriptions from existing metadata rather than inventing summaries. - Keep `llms.txt`, Markdown copies, sitemap data, redirects, and freshness metadata generated from the same mdBook source to avoid drift. - Advertise Markdown alternates with standard HTML metadata and same-origin URLs. - Support both explicit Markdown URLs and content negotiation for clients that prefer Markdown. - Keep browser copy behavior pointed at generated Markdown alternate links instead of duplicating route inference in JavaScript. - Keep the copy-as-Markdown affordance in the page title row without duplicating header chrome controls. Validation - `cargo check -p docs_preprocessor` - `cargo test -p docs_preprocessor` - `./script/clippy -p docs_preprocessor` - `mdbook build ./docs --dest-dir=../target/deploy/docs/` - `node --check docs/theme/plugins.js` - `pnpm dlx prettier@3.5.0 docs/theme/plugins.js --check` - `git diff --check` - Local artifact checks confirmed generated Markdown pages, `llms.txt`, `sitemap.xml` lastmod values, HTML freshness metadata, Markdown alternate links, redirect targets, and preprocessed action/keybinding tags resolve as expected. - Worker URL rewrite mock covered `/docs/`, `/docs.md`, `/docs/index.md`, extensionless docs routes, `.html` routes, `/docs/llms.txt`, and `/docs/sitemap.xml`. - High-effort adversarial subagent review found blockers around channel-aware redirects, shallow-checkout date fallback, file size, duplicated Markdown path inference, and process spawning. Those were addressed. Remaining Notes - Local `python -m http.server` does not emulate Cloudflare Pages pretty URLs, `_redirects`, or the docs-proxy Worker, so full local `afdocs` still cannot prove content negotiation end to end. - Existing production remains unchanged until this PR is deployed through the docs workflow. - Production baseline `npx afdocs check https://zed.dev/docs/ --fixes --verbose` still reports the original failures before this PR is deployed: 12 passed, 8 failed, 3 skipped. Release Notes: - Improved docs AI-readiness by adding machine-readable discovery, Markdown access, and freshness metadata. --------- Co-authored-by: Katie Geer Co-authored-by: Ben Kunkle --- .cloudflare/docs-proxy/src/worker.js | 41 ++ crates/docs_preprocessor/Cargo.toml | 2 +- crates/docs_preprocessor/src/ai_discovery.rs | 549 +++++++++++++++++++ crates/docs_preprocessor/src/main.rs | 122 ++--- crates/docs_preprocessor/src/tests.rs | 61 +++ docs/book.toml | 4 +- docs/theme/css/chrome.css | 1 + docs/theme/css/general.css | 1 + docs/theme/index.hbs | 182 +++--- docs/theme/plugins.css | 64 +++ docs/theme/plugins.js | 173 ++++-- 11 files changed, 982 insertions(+), 218 deletions(-) create mode 100644 crates/docs_preprocessor/src/ai_discovery.rs create mode 100644 crates/docs_preprocessor/src/tests.rs diff --git a/.cloudflare/docs-proxy/src/worker.js b/.cloudflare/docs-proxy/src/worker.js index 08b0265fafb..99fca0c7116 100644 --- a/.cloudflare/docs-proxy/src/worker.js +++ b/.cloudflare/docs-proxy/src/worker.js @@ -1,6 +1,11 @@ export default { async fetch(request, _env, _ctx) { const url = new URL(request.url); + const acceptHeader = request.headers.get("Accept") || ""; + const wantsMarkdown = acceptHeader + .split(",") + .map((mediaType) => mediaType.split(";")[0].trim().toLowerCase()) + .includes("text/markdown"); if (url.pathname === "/docs/nightly") { url.hostname = "docs-nightly.pages.dev"; @@ -18,6 +23,14 @@ export default { url.hostname = "docs-anw.pages.dev"; } + if (url.pathname === "/docs.md") { + url.pathname = "/docs/getting-started.md"; + } + + if (wantsMarkdown) { + url.pathname = markdownPathFor(url.pathname); + } + let res = await fetch(url, request); if (res.status === 404) { @@ -27,3 +40,31 @@ export default { return res; }, }; + +function markdownPathFor(pathname) { + if (pathname === "/docs" || pathname === "/docs/") { + return "/docs/getting-started.md"; + } + + if (pathname.endsWith("/index.md")) { + return pathname.replace(/\/index\.md$/, "/getting-started.md"); + } + + if (pathname.endsWith(".md")) { + return pathname; + } + + if (pathname.endsWith(".html")) { + return pathname.replace(/\.html$/, ".md"); + } + + if (pathname.split("/").pop().includes(".")) { + return pathname; + } + + if (pathname.endsWith("/")) { + return `${pathname}getting-started.md`; + } + + return `${pathname}.md`; +} diff --git a/crates/docs_preprocessor/Cargo.toml b/crates/docs_preprocessor/Cargo.toml index 87e5110ff52..a422d2811db 100644 --- a/crates/docs_preprocessor/Cargo.toml +++ b/crates/docs_preprocessor/Cargo.toml @@ -27,4 +27,4 @@ workspace = true [[bin]] name = "docs_preprocessor" -path = "src/main.rs" \ No newline at end of file +path = "src/main.rs" diff --git a/crates/docs_preprocessor/src/ai_discovery.rs b/crates/docs_preprocessor/src/ai_discovery.rs new file mode 100644 index 00000000000..4f3f9195827 --- /dev/null +++ b/crates/docs_preprocessor/src/ai_discovery.rs @@ -0,0 +1,549 @@ +use anyhow::{Context, Result}; +use mdbook::BookItem; +use mdbook::book::Book; +use regex::Regex; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use crate::FRONT_MATTER_COMMENT; + +#[derive(Debug)] +pub(crate) struct DocsPage { + section: String, + title: String, + description: Option, + pub(crate) source_path: PathBuf, + content: String, +} + +pub(crate) fn write_ai_discovery_artifacts( + pages: &[DocsPage], + destination: &Path, + site_url: &str, +) -> Result<()> { + copy_markdown_sources(destination, site_url, pages)?; + write_llms_txt(destination, site_url, pages)?; + write_sitemap_xml(destination, site_url, pages)?; + Ok(()) +} + +pub(crate) fn docs_pages(book: &Book) -> Result> { + let mut pages = Vec::new(); + let mut section = "Docs".to_string(); + for item in book.iter() { + let BookItem::Chapter(chapter) = item else { + if let BookItem::PartTitle(part_title) = item { + section.clone_from(part_title); + } + continue; + }; + let Some(source_path) = chapter.source_path.as_ref() else { + continue; + }; + if source_path == Path::new("SUMMARY.md") { + continue; + } + pages.push(DocsPage { + section: section.clone(), + title: chapter.name.clone(), + description: docs_page_description(&chapter.content), + source_path: source_path.clone(), + content: chapter.content.clone(), + }); + } + Ok(pages) +} + +fn copy_markdown_sources(destination: &Path, site_url: &str, pages: &[DocsPage]) -> Result<()> { + for page in pages { + let destination = destination.join(&page.source_path); + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!("failed to create markdown destination {}", parent.display()) + })?; + } + let contents = rewrite_docs_links(&markdown_source_contents(&page.content), site_url); + std::fs::write( + &destination, + add_llms_markdown_directive(&contents, site_url), + ) + .with_context(|| { + format!( + "failed to write markdown page {} to {}", + page.source_path.display(), + destination.display() + ) + })?; + } + let getting_started = destination.join("getting-started.md"); + if getting_started.exists() { + std::fs::copy(&getting_started, destination.join("index.md")) + .context("failed to write index.md markdown alias")?; + } + Ok(()) +} + +fn markdown_source_contents(contents: &str) -> String { + front_matter_comment_regex() + .replace(contents, "") + .trim_start() + .to_string() +} + +fn docs_page_description(contents: &str) -> Option { + docs_page_metadata(contents).and_then(|metadata| { + metadata + .get("description") + .map(|description| { + description + .trim() + .trim_matches('"') + .split_whitespace() + .collect::>() + .join(" ") + }) + .filter(|description| !description.is_empty()) + }) +} + +fn docs_page_metadata(contents: &str) -> Option> { + let captures = front_matter_comment_regex().captures(contents)?; + serde_json::from_str(&captures[1]).ok() +} + +fn front_matter_comment_regex() -> &'static Regex { + static FRONT_MATTER_COMMENT_REGEX: OnceLock = OnceLock::new(); + FRONT_MATTER_COMMENT_REGEX + .get_or_init(|| Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "([^\\n]*)")).unwrap()) +} + +fn write_llms_txt(destination: &Path, site_url: &str, pages: &[DocsPage]) -> Result<()> { + let mut contents = String::new(); + contents.push_str("# Zed Docs\n\n"); + contents.push_str( + "> Official Zed documentation index with links to Markdown versions of each docs page.\n\n", + ); + contents.push_str( + "Use these links for concise Markdown copies of Zed documentation pages. Each linked page mirrors the corresponding `/docs/*.html` page without site navigation or styling.\n\n", + ); + let mut current_section = None; + for page in pages { + if current_section != Some(page.section.as_str()) { + if current_section.is_some() { + contents.push('\n'); + } + contents.push_str("## "); + contents.push_str(&markdown_text(&page.section)); + contents.push_str("\n\n"); + current_section = Some(page.section.as_str()); + } + contents.push_str("- ["); + contents.push_str(&markdown_text(&page.title)); + contents.push_str("]("); + contents.push_str(&absolute_docs_url(site_url, &page.source_path)); + contents.push(')'); + if let Some(description) = &page.description { + contents.push_str(": "); + contents.push_str(&markdown_text(description)); + } + contents.push('\n'); + } + std::fs::write(destination.join("llms.txt"), contents).context("failed to write llms.txt")?; + Ok(()) +} + +fn markdown_text(text: &str) -> String { + text.replace('\\', "\\\\") + .replace('[', "\\[") + .replace(']', "\\]") +} + +fn write_sitemap_xml(destination: &Path, site_url: &str, pages: &[DocsPage]) -> Result<()> { + let mut contents = String::new(); + contents.push_str("\n"); + contents.push_str("\n"); + for page in pages { + contents.push_str(" "); + contents.push_str(&xml_escape(&absolute_docs_url( + site_url, + &page.source_path.with_extension("html"), + ))); + contents.push_str(""); + contents.push_str("\n"); + } + contents.push_str("\n"); + std::fs::write(destination.join("sitemap.xml"), contents) + .context("failed to write sitemap.xml")?; + Ok(()) +} + +pub(crate) fn write_pages_redirects( + destination: &Path, + redirects: &[(String, String)], + site_url: &str, +) -> Result<()> { + let Some(deploy_root) = destination.parent() else { + return Ok(()); + }; + let mut contents = String::new(); + for (source, destination) in redirects { + write_redirect_line( + &mut contents, + &docs_path("/docs/", source), + &redirect_destination(site_url, destination), + ); + if let Some(extensionless_source) = strip_html_suffix(source) { + write_redirect_line( + &mut contents, + &docs_path("/docs/", &extensionless_source), + &redirect_destination( + site_url, + &strip_html_suffix(destination).unwrap_or_else(|| destination.to_string()), + ), + ); + } + if let Some(markdown_source) = html_path_to_markdown(source) { + if let Some(markdown_destination) = html_path_to_markdown(destination) { + write_redirect_line( + &mut contents, + &docs_path("/docs/", &markdown_source), + &redirect_destination(site_url, &markdown_destination), + ); + } + } + } + std::fs::write(deploy_root.join("_redirects"), contents) + .context("failed to write Cloudflare Pages _redirects")?; + Ok(()) +} + +pub(crate) fn write_markdown_redirect_aliases( + destination: &Path, + redirects: &[(String, String)], + site_url: &str, +) -> Result<()> { + for (source, redirect_destination_path) in redirects { + let Some(source_markdown) = html_path_to_markdown(source) else { + continue; + }; + let Some(destination_markdown) = html_path_to_markdown(redirect_destination_path) else { + continue; + }; + let source_markdown = destination.join(source_markdown.trim_start_matches('/')); + let destination_markdown = + destination.join(destination_markdown.trim_start_matches("/docs/")); + if !destination_markdown.exists() { + continue; + } + if let Some(parent) = source_markdown.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create markdown alias directory {}", + parent.display() + ) + })?; + } + let contents = format!( + "# Moved\n\n> For the complete documentation index and Markdown links, see [llms.txt]({}).\n\nThis page moved to [the current docs page]({}).\n", + docs_url(site_url, Path::new("llms.txt")), + html_path_to_markdown(redirect_destination_path) + .map(|path| redirect_destination(site_url, &path)) + .unwrap_or_else(|| redirect_destination(site_url, redirect_destination_path)) + ); + std::fs::write(&source_markdown, contents).with_context(|| { + format!( + "failed to write markdown redirect alias from {} to {}", + redirect_destination_path, + source_markdown.display() + ) + })?; + } + Ok(()) +} + +fn write_redirect_line(contents: &mut String, source: &str, destination: &str) { + contents.push_str(source); + contents.push(' '); + contents.push_str(destination); + contents.push_str(" 301\n"); +} + +fn docs_path(site_url: &str, path: &str) -> String { + docs_url(site_url, Path::new(path.trim_start_matches('/'))) +} + +fn redirect_destination(site_url: &str, destination: &str) -> String { + if let Some(path) = destination.strip_prefix("/docs/") { + docs_url(site_url, Path::new(path)) + } else if destination == "/docs" { + docs_url(site_url, Path::new("")) + } else { + destination.to_string() + } +} + +fn strip_html_suffix(path: &str) -> Option { + let (path, fragment) = split_fragment(path); + let path = path.strip_suffix(".html")?; + Some(format!("{path}{fragment}")) +} + +fn html_path_to_markdown(path: &str) -> Option { + let (path, fragment) = split_fragment(path); + if !path.starts_with("/docs/") && path != "/docs" && !path.ends_with(".html") { + return None; + } + let markdown_path = path.strip_suffix(".html").unwrap_or(path); + Some(format!("{markdown_path}.md{fragment}")) +} + +fn split_fragment(path: &str) -> (&str, &str) { + match path.find('#') { + Some(index) => (&path[..index], &path[index..]), + None => (path, ""), + } +} + +pub(crate) fn rewrite_docs_links(contents: &str, site_url: &str) -> String { + const STABLE_DOCS_PREFIX: &str = "https://zed.dev/docs/"; + let channel_docs_prefix = absolute_docs_url(site_url, Path::new("")); + if channel_docs_prefix == STABLE_DOCS_PREFIX { + return contents.to_string(); + } + + let mut output = String::with_capacity(contents.len()); + let mut remaining = contents; + while let Some(index) = remaining.find(STABLE_DOCS_PREFIX) { + output.push_str(&remaining[..index]); + let after_prefix = &remaining[index + STABLE_DOCS_PREFIX.len()..]; + if after_prefix.starts_with("preview/") || after_prefix.starts_with("nightly/") { + output.push_str(STABLE_DOCS_PREFIX); + } else { + output.push_str(&channel_docs_prefix); + } + remaining = after_prefix; + } + output.push_str(remaining); + output +} + +pub(crate) fn add_markdown_alternate_link( + contents: &str, + html_file: &Path, + root_dir: &Path, + site_url: &str, +) -> String { + let Ok(relative_path) = html_file.strip_prefix(root_dir) else { + return contents.to_string(); + }; + let markdown_path = relative_path.with_extension("md"); + if !root_dir.join(&markdown_path).exists() { + return contents.to_string(); + } + let markdown_url = docs_url(site_url, &markdown_path); + let link = format!( + " \n", + markdown_url + ); + contents.replacen("", &(link + " "), 1) +} + +fn add_llms_markdown_directive(contents: &str, site_url: &str) -> String { + let directive = format!( + "> For the complete documentation index and Markdown links, see [llms.txt]({}).\n\n", + docs_url(site_url, Path::new("llms.txt")), + ); + if let Some(rest) = contents.strip_prefix("---\n") { + if let Some(frontmatter_end) = rest.find("\n---\n") { + let split_at = "---\n".len() + frontmatter_end + "\n---\n".len(); + let mut output = String::with_capacity(contents.len() + directive.len()); + output.push_str(&contents[..split_at]); + output.push('\n'); + output.push_str(&directive); + output.push_str(&contents[split_at..]); + return output; + } + } + + let mut output = String::with_capacity(contents.len() + directive.len()); + output.push_str(&directive); + output.push_str(contents); + output +} + +fn docs_url(site_url: &str, path: &Path) -> String { + let mut url = site_url.to_string(); + if !url.ends_with('/') { + url.push('/'); + } + url.push_str(&path.to_string_lossy().replace('\\', "/")); + url +} + +fn absolute_docs_url(site_url: &str, path: &Path) -> String { + let url = docs_url(site_url, path); + if url.starts_with("http://") || url.starts_with("https://") { + url + } else { + format!("https://zed.dev{}", url) + } +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add_llms_markdown_directive_inserts_after_frontmatter() { + let contents = "---\ntitle: Example\n---\n# Example\n"; + let output = add_llms_markdown_directive(contents, "/docs/"); + + assert!(output.starts_with("---\ntitle: Example\n---\n\n")); + assert!(output.contains( + "> For the complete documentation index and Markdown links, see [llms.txt](/docs/llms.txt)." + )); + } + + #[test] + fn test_redirect_destination_uses_channel_site_url_for_docs_paths() { + assert_eq!( + redirect_destination("/docs/preview/", "/docs/ai/overview.html"), + "/docs/preview/ai/overview.html" + ); + assert_eq!( + redirect_destination("/docs/preview/", "/community-links"), + "/community-links" + ); + } + + #[test] + fn test_rewrite_docs_links_uses_channel_site_url() { + assert_eq!( + rewrite_docs_links( + "See [Code Actions](https://zed.dev/docs/configuring-languages#code-actions) and [Preview](https://zed.dev/docs/preview/ai/overview.html).", + "/docs/preview/" + ), + "See [Code Actions](https://zed.dev/docs/preview/configuring-languages#code-actions) and [Preview](https://zed.dev/docs/preview/ai/overview.html)." + ); + } + + #[test] + fn test_docs_path_uses_channel_site_url() { + assert_eq!( + docs_path("/docs/preview/", "/assistant.md"), + "/docs/preview/assistant.md" + ); + } + + #[test] + fn test_write_pages_redirects_keeps_sources_on_internal_pages_path() -> Result<()> { + let deploy_root = std::env::temp_dir().join(format!( + "docs_preprocessor_pages_redirects_test_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + )); + let destination = deploy_root.join("docs"); + std::fs::create_dir_all(&destination)?; + let redirects = vec![ + ( + "/assistant.html".to_string(), + "/docs/ai/overview.html".to_string(), + ), + ( + "/community/feedback.html".to_string(), + "/community-links".to_string(), + ), + ]; + + write_pages_redirects(&destination, &redirects, "/docs/preview/")?; + + assert_eq!( + std::fs::read_to_string(deploy_root.join("_redirects"))?, + "/docs/assistant.html /docs/preview/ai/overview.html 301\n\ +/docs/assistant /docs/preview/ai/overview 301\n\ +/docs/assistant.md /docs/preview/ai/overview.md 301\n\ +/docs/community/feedback.html /community-links 301\n\ +/docs/community/feedback /community-links 301\n" + ); + std::fs::remove_dir_all(&deploy_root)?; + Ok(()) + } + + #[test] + fn test_write_ai_discovery_artifacts_generates_agent_facing_metadata() -> Result<()> { + let destination = std::env::temp_dir().join(format!( + "docs_preprocessor_ai_discovery_test_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + )); + std::fs::create_dir_all(&destination)?; + + let pages = vec![ + DocsPage { + section: "Docs".to_string(), + title: "Getting Started".to_string(), + description: Some("Start using Zed.".to_string()), + source_path: PathBuf::from("getting-started.md"), + content: format!( + "{}\n# Getting Started\n", + FRONT_MATTER_COMMENT.replace("{}", r#"{"description":"Start using Zed."}"#) + ), + }, + DocsPage { + section: "AI".to_string(), + title: "MCP".to_string(), + description: Some("Connect model context servers.".to_string()), + source_path: PathBuf::from("ai/mcp.md"), + content: format!( + "{}\n# MCP\n", + FRONT_MATTER_COMMENT + .replace("{}", r#"{"description":"Connect model context servers."}"#) + ), + }, + ]; + + write_ai_discovery_artifacts(&pages, &destination, "/docs/")?; + + let llms_txt = std::fs::read_to_string(destination.join("llms.txt"))?; + assert!(llms_txt.contains("## Docs")); + assert!(llms_txt.contains( + "- [Getting Started](https://zed.dev/docs/getting-started.md): Start using Zed." + )); + assert!(llms_txt.contains("## AI")); + assert!( + llms_txt.contains( + "- [MCP](https://zed.dev/docs/ai/mcp.md): Connect model context servers." + ) + ); + + let sitemap_xml = std::fs::read_to_string(destination.join("sitemap.xml"))?; + assert!(sitemap_xml.contains("https://zed.dev/docs/getting-started.html")); + assert!(sitemap_xml.contains("https://zed.dev/docs/ai/mcp.html")); + + let mcp_markdown = std::fs::read_to_string(destination.join("ai/mcp.md"))?; + assert!(mcp_markdown.starts_with( + "> For the complete documentation index and Markdown links, see [llms.txt](/docs/llms.txt).\n\n# MCP" + )); + assert!(!mcp_markdown.contains("ZED_META")); + + let index_markdown = std::fs::read_to_string(destination.join("index.md"))?; + assert!(index_markdown.contains("# Getting Started")); + + std::fs::remove_dir_all(&destination)?; + Ok(()) + } +} diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index df3b556d6a4..4b0c2ca04bc 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -1,4 +1,10 @@ use anyhow::{Context, Result}; +mod ai_discovery; + +use ai_discovery::{ + add_markdown_alternate_link, docs_pages, rewrite_docs_links, write_ai_discovery_artifacts, + write_markdown_redirect_aliases, write_pages_redirects, +}; use mdbook::BookItem; use mdbook::book::{Book, Chapter}; use mdbook::preprocess::CmdPreprocessor; @@ -188,7 +194,7 @@ fn handle_preprocessing() -> Result<()> { template_big_table_of_actions(&mut book); template_and_validate_keybindings(&mut book, &mut errors); template_and_validate_actions(&mut book, &mut errors); - template_and_validate_json_snippets(&mut book, &mut errors); + template_and_validate_json_snippets(&mut book, &mut errors)?; if !errors.is_empty() { const ANSI_RED: &str = "\x1b[31m"; @@ -252,7 +258,7 @@ fn format_binding(binding: String) -> String { } fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet) { - let regex = Regex::new(r"\{#kb(?::(\w+))?\s+(.*?)\}").unwrap(); + let regex = Regex::new(r"(?s)\{#kb(?::(\w+))?\s+(.*?)\}").unwrap(); for_each_chapter_mut(book, |chapter| { chapter.content = regex @@ -300,7 +306,7 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet) { - let regex = Regex::new(r"\{#action (.*?)\}").unwrap(); + let regex = Regex::new(r"(?s)\{#action\s+(.*?)\}").unwrap(); for_each_chapter_mut(book, |chapter| { chapter.content = regex @@ -379,7 +385,10 @@ fn find_binding_with_overlay( .or_else(|| find_binding(os, action)) } -fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet) { +fn template_and_validate_json_snippets( + book: &mut Book, + errors: &mut HashSet, +) -> Result<()> { let params = SettingsJsonSchemaParams { language_names: &[], font_names: &[], @@ -393,7 +402,7 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet
 Result<()> {
         .as_table_mut()
         .expect("output is table");
     let zed_html = output.remove("zed-html").expect("zed-html output defined");
+    let redirects = zed_html
+        .get("redirect")
+        .and_then(|redirects| redirects.as_table())
+        .map(|redirects| {
+            redirects
+                .iter()
+                .filter_map(|(source, destination)| {
+                    destination
+                        .as_str()
+                        .map(|destination| (source.clone(), destination.to_string()))
+                })
+                .collect::>()
+        });
     let default_description = zed_html
         .get("default-description")
         .expect("Default description not found")
@@ -694,6 +718,17 @@ fn handle_postprocessing() -> Result<()> {
     let amplitude_key = std::env::var("DOCS_AMPLITUDE_API_KEY").unwrap_or_default();
     let consent_io_instance = std::env::var("DOCS_CONSENT_IO_INSTANCE").unwrap_or_default();
     let docs_channel = std::env::var("DOCS_CHANNEL").unwrap_or_else(|_| "stable".to_string());
+    let site_url = std::env::var("MDBOOK_BOOK__SITE_URL")
+        .ok()
+        .filter(|site_url| !site_url.trim().is_empty())
+        .unwrap_or_else(|| {
+            match docs_channel.as_str() {
+                "nightly" => "/docs/nightly/",
+                "preview" => "/docs/preview/",
+                _ => "/docs/",
+            }
+            .to_string()
+        });
     let noindex = if docs_channel == "nightly" || docs_channel == "preview" {
         ""
     } else {
@@ -733,8 +768,10 @@ fn handle_postprocessing() -> Result<()> {
     }
 
     zlog::info!(logger => "Processing {} `.html` files", files.len());
+    let pages = docs_pages(&ctx.book)?;
+    write_ai_discovery_artifacts(&pages, &root_dir, &site_url)?;
     let meta_regex = Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "(.*)")).unwrap();
-    for file in files {
+    for file in &files {
         let contents = std::fs::read_to_string(&file)?;
         let mut meta_description = None;
         let mut meta_title = None;
@@ -770,14 +807,19 @@ fn handle_postprocessing() -> Result<()> {
         let contents = contents.replace("#amplitude_key#", &litude_key);
         let contents = contents.replace("#consent_io_instance#", &consent_io_instance);
         let contents = contents.replace("#noindex#", noindex);
+        let contents = rewrite_docs_links(&contents, &site_url);
+        let contents = add_markdown_alternate_link(&contents, file, &root_dir, &site_url);
         let contents = title_regex()
             .replace(&contents, |_: ®ex::Captures| {
                 format!("{}", meta_title)
             })
             .to_string();
-        // let contents = contents.replace("#title#", &meta_title);
         std::fs::write(file, contents)?;
     }
+    if let Some(redirects) = redirects {
+        write_markdown_redirect_aliases(&root_dir, &redirects, &site_url)?;
+        write_pages_redirects(&root_dir, &redirects, &site_url)?;
+    }
     return Ok(());
 
     fn pretty_path<'a>(
@@ -906,66 +948,4 @@ fn keymap_schema_for_actions(
 }
 
 #[cfg(test)]
-mod tests {
-    use super::*;
-    use serde_json::json;
-
-    #[test]
-    fn test_find_binding_prefers_exact_match_over_parameterized() {
-        let keymap: KeymapFile = serde_json::from_value(json!([
-            {
-                "bindings": {
-                    "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
-                    "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
-                }
-            }
-        ]))
-        .unwrap();
-
-        let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
-        assert_eq!(binding.as_deref(), Some("ctrl-tab"));
-    }
-
-    #[test]
-    fn test_find_binding_falls_back_to_parameterized_match() {
-        let keymap: KeymapFile = serde_json::from_value(json!([
-            {
-                "bindings": {
-                    "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
-                }
-            }
-        ]))
-        .unwrap();
-
-        let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
-        assert_eq!(binding.as_deref(), Some("ctrl-shift-tab"));
-    }
-
-    #[test]
-    fn test_find_binding_prefers_exact_match_regardless_of_order() {
-        let keymap: KeymapFile = serde_json::from_value(json!([
-            {
-                "bindings": {
-                    "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
-                    "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher"
-                }
-            }
-        ]))
-        .unwrap();
-
-        let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
-        assert_eq!(binding.as_deref(), Some("ctrl-tab"));
-    }
-
-    #[test]
-    fn test_find_binding_later_section_overrides_earlier() {
-        let keymap: KeymapFile = serde_json::from_value(json!([
-            { "bindings": { "ctrl-a": "some::Action" } },
-            { "bindings": { "ctrl-b": "some::Action" } }
-        ]))
-        .unwrap();
-
-        let binding = find_binding_in_keymap(&keymap, "some::Action");
-        assert_eq!(binding.as_deref(), Some("ctrl-b"));
-    }
-}
+mod tests;
diff --git a/crates/docs_preprocessor/src/tests.rs b/crates/docs_preprocessor/src/tests.rs
new file mode 100644
index 00000000000..43438207d8c
--- /dev/null
+++ b/crates/docs_preprocessor/src/tests.rs
@@ -0,0 +1,61 @@
+use super::*;
+use serde_json::json;
+
+#[test]
+fn test_find_binding_prefers_exact_match_over_parameterized() {
+    let keymap: KeymapFile = serde_json::from_value(json!([
+        {
+            "bindings": {
+                "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
+                "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
+            }
+        }
+    ]))
+    .unwrap();
+
+    let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
+    assert_eq!(binding.as_deref(), Some("ctrl-tab"));
+}
+
+#[test]
+fn test_find_binding_falls_back_to_parameterized_match() {
+    let keymap: KeymapFile = serde_json::from_value(json!([
+        {
+            "bindings": {
+                "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
+            }
+        }
+    ]))
+    .unwrap();
+
+    let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
+    assert_eq!(binding.as_deref(), Some("ctrl-shift-tab"));
+}
+
+#[test]
+fn test_find_binding_prefers_exact_match_regardless_of_order() {
+    let keymap: KeymapFile = serde_json::from_value(json!([
+        {
+            "bindings": {
+                "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
+                "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher"
+            }
+        }
+    ]))
+    .unwrap();
+
+    let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
+    assert_eq!(binding.as_deref(), Some("ctrl-tab"));
+}
+
+#[test]
+fn test_find_binding_later_section_overrides_earlier() {
+    let keymap: KeymapFile = serde_json::from_value(json!([
+        { "bindings": { "ctrl-a": "some::Action" } },
+        { "bindings": { "ctrl-b": "some::Action" } }
+    ]))
+    .unwrap();
+
+    let binding = find_binding_in_keymap(&keymap, "some::Action");
+    assert_eq!(binding.as_deref(), Some("ctrl-b"));
+}
diff --git a/docs/book.toml b/docs/book.toml
index 2eed5f7907a..e1a48d1f7f0 100644
--- a/docs/book.toml
+++ b/docs/book.toml
@@ -45,10 +45,10 @@ enable = false
 "/assistant.html" = "/docs/assistant/assistant.html"
 "/assistant/assistant-panel.html" = "/docs/ai/agent-panel.html"
 "/assistant/assistant.html" = "/docs/ai/overview.html"
-"/assistant/commands.html" = "/docs/ai/text-threads.html"
+"/assistant/commands.html" = "/docs/ai/agent-panel.html"
 "/assistant/configuration.html" = "/docs/ai/quick-start.html"
 "/assistant/context-servers.html" = "/docs/ai/mcp.html"
-"/assistant/contexts.html" = "/docs/ai/text-threads.html"
+"/assistant/contexts.html" = "/docs/ai/agent-panel.html"
 "/assistant/inline-assistant.html" = "/docs/ai/inline-assistant.html"
 "/assistant/model-context-protocol.html" = "/docs/ai/mcp.html"
 "/assistant/prompting.html" = "/docs/ai/skills.html"
diff --git a/docs/theme/css/chrome.css b/docs/theme/css/chrome.css
index 8f5b40cc19e..f0169fd7c87 100644
--- a/docs/theme/css/chrome.css
+++ b/docs/theme/css/chrome.css
@@ -434,6 +434,7 @@ ul#searchresults span.teaser em {
 
 .sidebar {
   position: relative;
+  order: 0;
   width: var(--sidebar-width);
   flex-shrink: 0;
   display: flex;
diff --git a/docs/theme/css/general.css b/docs/theme/css/general.css
index 9c8077bad52..d32edaa8d61 100644
--- a/docs/theme/css/general.css
+++ b/docs/theme/css/general.css
@@ -201,6 +201,7 @@ hr {
 
 .page {
   outline: 0;
+  order: 1;
   flex: 1;
   display: flex;
   flex-direction: column;
diff --git a/docs/theme/index.hbs b/docs/theme/index.hbs
index 2c7786817aa..d5e0b620051 100644
--- a/docs/theme/index.hbs
+++ b/docs/theme/index.hbs
@@ -27,6 +27,7 @@
             })();
         
         {{ title }}
+        
         {{#if is_print }}
         
         {{/if}}
@@ -86,6 +87,9 @@
             document.body.classList.remove('no-js');
             document.body.classList.add('js');
         
+        
+ Agent documentation index: llms.txt. Markdown versions are available for docs pages. +
@@ -155,6 +159,95 @@
{{/if}} +
+
+
+ {{{ content }}} + + +
+
+ +
+ + +
+
+