From 4a99aa870e3e96a5350b7d707da344ca8e9a21df Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Tue, 30 Jun 2026 12:10:04 +0100 Subject: [PATCH 001/422] agent: More sandboxing changes (#60111) Large change to sandboxing: - fixes a nasty TOCTOU relating to a symlink swap attack, documented in the `sandboxing/README.md` - Adds UI and restrictions when in an untrusted workspace - Adds tests for (soon to be removed) git support --- Release Notes: - N/A or Added/Fixed/Improved ... --- Cargo.lock | 3 + crates/acp_thread/src/acp_thread.rs | 17 +- crates/acp_thread/src/terminal.rs | 51 +- crates/agent/Cargo.toml | 5 + crates/agent/src/sandboxing.rs | 66 +- crates/agent/src/thread.rs | 95 +- crates/agent/src/tools.rs | 34 + crates/agent/src/tools/fetch_tool.rs | 4 + crates/agent/src/tools/terminal_tool.rs | 72 +- crates/agent_settings/src/agent_profile.rs | 61 +- crates/agent_ui/src/conversation_view.rs | 11 + .../src/conversation_view/thread_view.rs | 251 ++-- crates/agent_ui/src/profile_selector.rs | 201 +++- crates/docs_preprocessor/src/main.rs | 46 +- crates/sandbox/Cargo.toml | 3 + crates/sandbox/README.md | 261 +++++ crates/sandbox/bind_source_toctou_test.sh | 29 + crates/sandbox/src/bwrap_test_helper.rs | 106 +- crates/sandbox/src/linux_bubblewrap.rs | 1025 +++++++++++++++-- crates/sandbox/src/macos_seatbelt.rs | 48 +- crates/sandbox/src/sandbox.rs | 486 +++++++- crates/sandbox/src/windows_wsl.rs | 238 +++- crates/sandbox/src/wsl_sandbox_test_helper.rs | 11 +- docs/README.md | 13 +- docs/book.toml | 2 +- nix/build.nix | 7 + nix/dev/flake.lock | 17 + nix/dev/flake.nix | 4 + nix/modules/devshells.nix | 42 +- nix/tests/sandboxing/default.nix | 137 +++ 30 files changed, 2971 insertions(+), 375 deletions(-) create mode 100644 crates/sandbox/README.md create mode 100755 crates/sandbox/bind_source_toctou_test.sh diff --git a/Cargo.lock b/Cargo.lock index ab1ad792fb7fa7..5ab20e6db9fd7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -288,6 +288,7 @@ dependencies = [ "quick-xml 0.38.3", "rand 0.9.4", "regex", + "release_channel", "reqwest_client", "rust-embed", "sandbox", @@ -11441,6 +11442,7 @@ dependencies = [ "cfg-if", "cfg_aliases 0.2.1", "libc", + "memoffset", ] [[package]] @@ -15972,6 +15974,7 @@ dependencies = [ "http_proxy", "libc", "log", + "nix 0.29.0", "serde", "serde_json", "smol", diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 2f495df96ae17d..d060ecc26a6d1a 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -3952,14 +3952,15 @@ impl AcpThread { let (task_command, task_args) = builder .redirect_stdin_to_dev_null() .build(Some(command.clone()), &args); - let (task_command, task_args, task_env, sandbox) = prepare_sandbox_wrap( - task_command, - task_args, - cwd.clone(), - sandbox_wrap, - env, - ) - .await?; + let (task_command, task_args, task_env, sandbox) = cx + .background_spawn(prepare_sandbox_wrap( + task_command, + task_args, + cwd.clone(), + sandbox_wrap, + env, + )) + .await?; (task_command, task_args, task_env, sandbox, cwd.clone()) }; let terminal = project diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index b39d1e52a4e2ac..7526ad4a1b3cfe 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -59,6 +59,13 @@ pub struct SandboxWrap { /// enforcing proxy binds a loopback port on this host, so it can only /// confine local commands; a remote terminal can't reach it. pub is_local: bool, + /// Windows/WSL only: `(release channel, version)` of the Linux `zed` to + /// provision inside WSL as the sandbox helper (version `latest` for dev + /// builds). Resolved by the agent (which can read the running app's release + /// info) and forwarded to the sandbox. `None` on other platforms, or when + /// the release can't be determined, in which case the WSL backend falls back + /// to running bwrap without in-sandbox bind validation. + pub wsl_zed_release: Option<(String, String)>, } #[derive(Clone, Debug, Default)] @@ -137,26 +144,32 @@ impl SandboxWrap { /// (fail-open), or refuse (fail-closed). It runs a brief probe subprocess on /// Linux, so call it off the main thread. On platforms whose sandbox can't /// fail to set up this way it always returns `Ok`. - pub fn can_create_sandbox( - &self, - cwd: Option<&std::path::Path>, - ) -> Result<(), LinuxWslSandboxError> { - sandbox::Sandbox::can_create(&self.to_policy(), cwd).map_err(LinuxWslSandboxError::from) + pub fn can_create_sandbox(&self) -> Result<(), LinuxWslSandboxError> { + sandbox::Sandbox::can_create(&self.to_policy()).map_err(LinuxWslSandboxError::from) } /// Translate this request into the cross-platform [`sandbox::SandboxPolicy`]. + /// + /// This is the enforcement-policy construction point, so it **captures** each + /// grant as a [`sandbox::HostFilesystemLocation`] (pinning the inode / canonical + /// path) rather than passing a re-resolvable path. A location that can't be + /// captured (e.g. it doesn't exist) is dropped from the grant — fail-closed. fn to_policy(&self) -> sandbox::SandboxPolicy { let fs = if self.allow_fs_write { sandbox::SandboxFsPolicy::Unrestricted } else { - sandbox::SandboxFsPolicy::Restricted { - writable_paths: self - .writable_paths - .iter() - .cloned() - .chain(self.extra_write_paths.iter().cloned()) - .collect(), - } + let writable_paths = self + .writable_paths + .iter() + .chain(self.extra_write_paths.iter()) + .filter_map(|path| { + // Create not-yet-existing writable grants (e.g. an approved + // scratch dir) so they can be captured and bound; best-effort. + let _ = std::fs::create_dir_all(path); + sandbox::HostFilesystemLocation::new(path).ok() + }) + .collect(); + sandbox::SandboxFsPolicy::Restricted { writable_paths } }; let network = match &self.network { SandboxNetworkAccess::None => sandbox::SandboxNetPolicy::Blocked, @@ -169,7 +182,11 @@ impl SandboxWrap { .collect(), }, }; - let git_dirs = self.git_dirs.clone(); + let git_dirs = self + .git_dirs + .iter() + .filter_map(|path| sandbox::HostFilesystemLocation::new(path).ok()) + .collect(); let git = if self.allow_git_access { sandbox::GitSandboxPolicy::Allowed { git_dirs } } else { @@ -238,6 +255,12 @@ pub(crate) async fn prepare_sandbox_wrap( let mut sandbox = sandbox::Sandbox::new(sandbox_wrap.to_policy()).map_err(anyhow::Error::new)?; + // Windows/WSL only: tell the sandbox which Linux `zed` to provision inside + // WSL as its `--wsl-sandbox-helper`. A no-op (and a no-op setter) elsewhere. + #[cfg(target_os = "windows")] + if let Some((channel, version)) = sandbox_wrap.wsl_zed_release.clone() { + sandbox.set_wsl_zed_release(channel, version); + } let command = sandbox::CommandAndArgs { program, args, diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 8420cbede15273..30eb959298ea7e 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -79,6 +79,11 @@ web_search.workspace = true zed_env_vars.workspace = true zstd.workspace = true +# Used only on Windows to resolve the running release channel/version so the WSL +# sandbox helper can fetch a matching Linux `zed`. +[target.'cfg(target_os = "windows")'.dependencies] +release_channel.workspace = true + [dev-dependencies] assets.workspace = true async-io.workspace = true diff --git a/crates/agent/src/sandboxing.rs b/crates/agent/src/sandboxing.rs index 2c5e32a02d4399..e8597a9412dd00 100644 --- a/crates/agent/src/sandboxing.rs +++ b/crates/agent/src/sandboxing.rs @@ -30,7 +30,9 @@ use feature_flags::{FeatureFlagAppExt as _, SandboxingFeatureFlag}; use gpui::App; use http_proxy::HostPattern; use project::Project; -use sandbox::{GitSandboxPolicy, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy}; +use sandbox::{ + GitSandboxPolicy, HostFilesystemLocation, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy, +}; use settings::Settings; use std::path::PathBuf; @@ -126,6 +128,13 @@ impl ThreadSandbox { match self { ThreadSandbox::Unsandboxed => ThreadSandbox::Unsandboxed, ThreadSandbox::Sandboxed(policy) => { + // Capture each `.git` location (pinning its inode / canonical + // path). A location that can't be captured (e.g. doesn't exist) + // is dropped — fail-closed. + let git_dirs = git_dirs + .into_iter() + .filter_map(|path| HostFilesystemLocation::new(path).ok()) + .collect(); let git = if allowed { GitSandboxPolicy::Allowed { git_dirs } } else { @@ -158,7 +167,11 @@ pub fn settings_sandbox_policy(persistent: &SandboxPermissions) -> SandboxPolicy SandboxFsPolicy::Unrestricted } else { SandboxFsPolicy::Restricted { - writable_paths: persistent.write_paths.clone(), + writable_paths: persistent + .write_paths + .iter() + .filter_map(|path| HostFilesystemLocation::new(path).ok()) + .collect(), } }; let network = if persistent.allow_all_hosts { @@ -437,7 +450,11 @@ impl ThreadSandboxGrants { SandboxFsPolicy::Unrestricted } else { SandboxFsPolicy::Restricted { - writable_paths: self.write_paths.clone(), + writable_paths: self + .write_paths + .iter() + .filter_map(|path| HostFilesystemLocation::new(path).ok()) + .collect(), } }; let network = if self.network_any_host { @@ -613,6 +630,7 @@ pub(crate) fn insert_host_pattern(set: &mut Vec, pattern: HostPatte #[cfg(test)] mod tests { use super::*; + use std::path::Path; fn hosts(list: &[&str]) -> NetworkRequest { NetworkRequest::Hosts( @@ -644,9 +662,19 @@ mod tests { #[test] fn thread_sandbox_merge_unsandboxed_wins_else_unions_scopes() { - let policy = |paths: &[&str], hosts: &[&str]| SandboxPolicy { + // Writable paths are captured as real `HostFilesystemLocation`s (which + // open an fd and key on the inode), so the test uses real directories. + let dir_a = tempfile::tempdir().expect("create temp dir a"); + let dir_b = tempfile::tempdir().expect("create temp dir b"); + let path_a = dir_a.path(); + let path_b = dir_b.path(); + + let policy = |paths: &[&Path], hosts: &[&str]| SandboxPolicy { fs: SandboxFsPolicy::Restricted { - writable_paths: paths.iter().map(PathBuf::from).collect(), + writable_paths: paths + .iter() + .map(|p| HostFilesystemLocation::new(p).expect("capture temp dir")) + .collect(), }, network: if hosts.is_empty() { SandboxNetPolicy::Blocked @@ -661,20 +689,20 @@ mod tests { // Unsandboxed on either side wins — the agent runs with ambient access. assert!( ThreadSandbox::Unsandboxed - .merge(ThreadSandbox::Sandboxed(policy(&["/a"], &["a.com"]))) + .merge(ThreadSandbox::Sandboxed(policy(&[path_a], &["a.com"]))) .is_unsandboxed() ); assert!( - ThreadSandbox::Sandboxed(policy(&["/a"], &["a.com"])) + ThreadSandbox::Sandboxed(policy(&[path_a], &["a.com"])) .merge(ThreadSandbox::Unsandboxed) .is_unsandboxed() ); // Two sandboxed layers union their scopes. assert_eq!( - ThreadSandbox::Sandboxed(policy(&["/a"], &["a.com"])) - .merge(ThreadSandbox::Sandboxed(policy(&["/b"], &["b.com"]))), - ThreadSandbox::Sandboxed(policy(&["/a", "/b"], &["a.com", "b.com"])) + ThreadSandbox::Sandboxed(policy(&[path_a], &["a.com"])) + .merge(ThreadSandbox::Sandboxed(policy(&[path_b], &["b.com"]))), + ThreadSandbox::Sandboxed(policy(&[path_a, path_b], &["a.com", "b.com"])) ); } @@ -743,13 +771,19 @@ mod tests { fn thread_grants_to_policy_maps_paths_and_domains() { use sandbox::{SandboxFsPolicy, SandboxNetPolicy}; + // `to_policy` captures real `HostFilesystemLocation`s, so use a real dir. + let build_dir = tempfile::tempdir().expect("create temp build dir"); + let build_path = build_dir.path().to_str().expect("utf-8 temp path"); + let mut grants = ThreadSandboxGrants::default(); - grants.record(&request(hosts(&["github.com"]), false, &["/tmp/build"])); + grants.record(&request(hosts(&["github.com"]), false, &[build_path])); let policy = grants.to_policy(); assert_eq!( policy.fs, SandboxFsPolicy::Restricted { - writable_paths: vec![PathBuf::from("/tmp/build")] + writable_paths: vec![ + HostFilesystemLocation::new(build_dir.path()).expect("capture temp dir") + ] } ); assert_eq!( @@ -781,8 +815,10 @@ mod tests { fn settings_policy_maps_persistent_permissions() { use sandbox::{SandboxFsPolicy, SandboxNetPolicy}; + // `settings_sandbox_policy` captures real `HostFilesystemLocation`s. + let log_dir = tempfile::tempdir().expect("create temp log dir"); let persistent = SandboxPermissions { - write_paths: vec![PathBuf::from("/var/log")], + write_paths: vec![log_dir.path().to_path_buf()], network_hosts: vec!["*.npmjs.org".to_string()], ..Default::default() }; @@ -790,7 +826,9 @@ mod tests { assert_eq!( policy.fs, SandboxFsPolicy::Restricted { - writable_paths: vec![PathBuf::from("/var/log")] + writable_paths: vec![ + HostFilesystemLocation::new(log_dir.path()).expect("capture temp dir") + ] } ); assert_eq!( diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 704e48eef5e636..980ed067a3864f 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -18,8 +18,8 @@ use crate::sandboxing::{ use crate::tools::{SandboxGitPathCandidates, sandbox_git_paths}; use agent_client_protocol::schema::v1 as acp; use agent_settings::{ - AgentProfileId, AgentSettings, AutoCompactThreshold, COMPACTION_PROMPT, - SUMMARIZE_THREAD_DETAILED_PROMPT, SUMMARIZE_THREAD_PROMPT, + AgentProfileId, AgentProfileSettings, AgentSettings, AutoCompactThreshold, COMPACTION_PROMPT, + SUMMARIZE_THREAD_DETAILED_PROMPT, SUMMARIZE_THREAD_PROMPT, builtin_profiles, }; use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Local, Utc}; @@ -46,7 +46,7 @@ use language_model::{ LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role, SelectedModel, Speed, StopReason, TokenUsage, ZED_CLOUD_PROVIDER_ID, }; -use project::Project; +use project::{Project, trusted_worktrees::TrustedWorktrees}; use prompt_store::ProjectContext; use schemars::{JsonSchema, Schema}; use serde::de::DeserializeOwned; @@ -1226,6 +1226,9 @@ pub struct Thread { initial_project_snapshot: Shared>>>, pub(crate) context_server_registry: Entity, profile_id: AgentProfileId, + /// Whether `profile_id` was downgraded to `minimal` at thread start because + /// the workspace is restricted. Used purely to surface a warning in the UI. + profile_downgraded_for_restricted_workspace: bool, project_context: Entity, pub(crate) templates: Arc, model: ThreadModel, @@ -1320,7 +1323,8 @@ impl Thread { cx: &mut Context, ) -> Self { let settings = AgentSettings::get_global(cx); - let profile_id = settings.default_profile.clone(); + let (profile_id, profile_downgraded_for_restricted_workspace) = + Self::profile_for_restricted_workspace(settings.default_profile.clone(), &project, cx); let enable_thinking = settings .default_model .as_ref() @@ -1363,6 +1367,7 @@ impl Thread { }, context_server_registry, profile_id, + profile_downgraded_for_restricted_workspace, project_context, templates, model, @@ -1395,6 +1400,8 @@ impl Thread { self.thinking_effort = parent.thinking_effort.clone(); self.summarization_model = parent.summarization_model.clone(); self.profile_id = parent.profile_id.clone(); + self.profile_downgraded_for_restricted_workspace = + parent.profile_downgraded_for_restricted_workspace; } fn apply_model_selection( @@ -1738,6 +1745,7 @@ impl Thread { initial_project_snapshot: Task::ready(db_thread.initial_project_snapshot).shared(), context_server_registry, profile_id, + profile_downgraded_for_restricted_workspace: false, project_context, templates, model, @@ -2195,7 +2203,44 @@ impl Thread { &self.profile_id } + /// Whether this thread's profile was downgraded to `minimal` at thread start + /// because the workspace is restricted. + pub fn profile_was_downgraded(&self) -> bool { + self.profile_downgraded_for_restricted_workspace + } + + /// Computes the profile a thread should start with, given the user's chosen + /// profile. In a restricted workspace, the built-in `write`/`ask` profiles + /// are downgraded to `minimal` — but only when both the chosen profile and + /// `minimal` are unmodified, shipped defaults, so we never override a user's + /// custom or customized profiles. + /// + /// Returns the (possibly downgraded) profile and whether a downgrade + /// happened. + fn profile_for_restricted_workspace( + profile_id: AgentProfileId, + project: &Entity, + cx: &App, + ) -> (AgentProfileId, bool) { + let is_write_or_ask = profile_id.as_str() == builtin_profiles::WRITE + || profile_id.as_str() == builtin_profiles::ASK; + let minimal = AgentProfileId(builtin_profiles::MINIMAL.into()); + if is_write_or_ask + && TrustedWorktrees::has_restricted_worktrees(&project.read(cx).worktree_store(), cx) + && AgentProfileSettings::is_unmodified_default(&profile_id, cx) + && AgentProfileSettings::is_unmodified_default(&minimal, cx) + { + (minimal, true) + } else { + (profile_id, false) + } + } + pub fn set_profile(&mut self, profile_id: AgentProfileId, cx: &mut Context) { + // An explicit selection means any earlier automatic downgrade no longer + // applies, even if the user re-selects the same profile. + self.profile_downgraded_for_restricted_workspace = false; + if self.profile_id == profile_id { return; } @@ -3458,6 +3503,26 @@ impl Thread { cancellation_rx: watch::Receiver, cx: &mut Context, ) -> Task { + // A workspace can become restricted after a thread has already started. + // Tools that aren't allowed in restricted workspaces must never run in + // that state, even though they were exposed to the model earlier. + if !tool.allow_in_restricted_mode() + && TrustedWorktrees::has_restricted_worktrees( + &self.project.read(cx).worktree_store(), + cx, + ) + { + return Task::ready(LanguageModelToolResult { + tool_use_id, + tool_name, + is_error: true, + content: vec![LanguageModelToolResultContent::Text(Arc::from( + "workspace has become restricted", + ))], + output: None, + }); + } + let fs = self.project.read(cx).fs().clone(); let tool_event_stream = ToolCallEventStream::new( tool_use_id.clone(), @@ -3936,9 +4001,16 @@ impl Thread { // to the model under that name. let use_sandboxed_terminal = sandboxing_enabled_for_project(self.project.read(cx), cx); + // Tools that aren't allowed in restricted workspaces must never be + // provided to the model while the workspace is restricted, regardless + // of what the active profile enables. + let is_restricted = + TrustedWorktrees::has_restricted_worktrees(&self.project.read(cx).worktree_store(), cx); + let mut tools = self .tools .iter() + .filter(|(_, tool)| !is_restricted || tool.allow_in_restricted_mode()) .filter_map(|(tool_name, tool)| { let terminal_variant = matches!( tool_name.as_ref(), @@ -4904,6 +4976,14 @@ where true } + /// Whether this tool may be provided to an agent in a restricted workspace. + /// + /// Tools that return `false` are never exposed to the model while the + /// workspace is restricted, and will fail if invoked in that state. + fn allow_in_restricted_mode() -> bool { + true + } + /// Runs the tool with the provided input. /// /// Returns `Result` rather than `Result` @@ -4967,6 +5047,9 @@ pub trait AnyAgentTool { fn supports_provider(&self, _provider: &LanguageModelProviderId) -> bool { true } + fn allow_in_restricted_mode(&self) -> bool { + true + } /// See [`AgentTool::run`] for why this returns `Result`. fn run( self: Arc, @@ -5018,6 +5101,10 @@ where T::supports_provider(provider) } + fn allow_in_restricted_mode(&self) -> bool { + T::allow_in_restricted_mode() + } + fn run( self: Arc, input: ToolInput, diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index 42d47a13275345..f55c16ae931929 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -138,6 +138,18 @@ macro_rules! tools { false } + /// Returns whether the tool with the given name may be provided to an + /// agent in a restricted workspace. Unknown tools (e.g. MCP tools) are + /// considered allowed. + pub fn tool_allowed_in_restricted_mode(name: &str) -> bool { + $( + if name == <$tool>::NAME { + return <$tool>::allow_in_restricted_mode(); + } + )* + true + } + /// A list of all built-in tools pub fn built_in_tools() -> impl Iterator { fn language_model_tool() -> LanguageModelRequestTool { @@ -219,3 +231,25 @@ pub fn tool_feature_flag_enabled(tool_name: &str, cx: &App) -> bool { _ => true, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fetch_and_terminal_are_forbidden_in_restricted_mode() { + assert!(!tool_allowed_in_restricted_mode(FetchTool::NAME)); + assert!(!tool_allowed_in_restricted_mode(TerminalTool::NAME)); + + // Every other built-in tool, and unknown (e.g. MCP) tools, are allowed. + for name in ALL_TOOL_NAMES { + let expected = *name != FetchTool::NAME && *name != TerminalTool::NAME; + assert_eq!( + tool_allowed_in_restricted_mode(name), + expected, + "unexpected restricted-mode policy for tool `{name}`" + ); + } + assert!(tool_allowed_in_restricted_mode("some_mcp_tool")); + } +} diff --git a/crates/agent/src/tools/fetch_tool.rs b/crates/agent/src/tools/fetch_tool.rs index 7bc93740af2d3c..5d6cc82dbba2b3 100644 --- a/crates/agent/src/tools/fetch_tool.rs +++ b/crates/agent/src/tools/fetch_tool.rs @@ -124,6 +124,10 @@ impl AgentTool for FetchTool { acp::ToolKind::Fetch } + fn allow_in_restricted_mode() -> bool { + false + } + fn initial_title( &self, input: Result, diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index 87d334689cbbcf..9dba295126491d 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -298,6 +298,10 @@ impl AgentTool for TerminalTool { acp::ToolKind::Execute } + fn allow_in_restricted_mode() -> bool { + false + } + fn initial_title( &self, input: Result, @@ -336,6 +340,10 @@ impl AgentTool for SandboxedTerminalTool { acp::ToolKind::Execute } + fn allow_in_restricted_mode() -> bool { + false + } + fn initial_title( &self, input: Result, @@ -372,6 +380,32 @@ fn terminal_initial_title(input: Result) -> SharedStr } } +/// Windows only: resolve the `(release channel, version)` of the Linux `zed` to +/// provision inside WSL as the sandbox helper. Dev (source) builds have no +/// matching release, so they pull the latest nightly; every other channel pins +/// its exact running version (stripped of pre-release/build metadata, which the +/// release API doesn't key on). +#[cfg(target_os = "windows")] +fn wsl_zed_release(cx: &App) -> Option<(String, String)> { + use release_channel::{AppVersion, ReleaseChannel}; + match *release_channel::RELEASE_CHANNEL { + ReleaseChannel::Dev => Some(("nightly".to_string(), "latest".to_string())), + channel => { + let version = AppVersion::global(cx); + Some(( + channel.dev_name().to_string(), + format!("{}.{}.{}", version.major, version.minor, version.patch), + )) + } + } +} + +/// Non-Windows platforms don't route through WSL, so there's no helper to fetch. +#[cfg(not(target_os = "windows"))] +fn wsl_zed_release(_cx: &App) -> Option<(String, String)> { + None +} + async fn run_terminal_tool( project: Entity, environment: Rc, @@ -382,17 +416,26 @@ async fn run_terminal_tool( let selection = input.selection; let sandbox_input = input.sandbox.clone().unwrap_or_default(); - let (working_dir, authorize, sandboxing, is_local_project) = cx.update(|cx| { - let working_dir = working_dir(&input.cd, &project, cx).map_err(|err| err.to_string())?; - let context = - crate::ToolPermissionContext::new(TerminalTool::NAME, vec![input.command.clone()]); - let authorize = - event_stream.authorize(SharedString::new(input.command.clone()), context, cx); - let sandboxing = - input.sandbox.is_some() && sandboxing_enabled_for_project(project.read(cx), cx); - let is_local_project = project.read(cx).is_local(); - Result::<_, String>::Ok((working_dir, authorize, sandboxing, is_local_project)) - })?; + let (working_dir, authorize, sandboxing, is_local_project, wsl_zed_release) = + cx.update(|cx| { + let working_dir = + working_dir(&input.cd, &project, cx).map_err(|err| err.to_string())?; + let context = + crate::ToolPermissionContext::new(TerminalTool::NAME, vec![input.command.clone()]); + let authorize = + event_stream.authorize(SharedString::new(input.command.clone()), context, cx); + let sandboxing = + input.sandbox.is_some() && sandboxing_enabled_for_project(project.read(cx), cx); + let is_local_project = project.read(cx).is_local(); + let wsl_zed_release = wsl_zed_release(cx); + Result::<_, String>::Ok(( + working_dir, + authorize, + sandboxing, + is_local_project, + wsl_zed_release, + )) + })?; authorize.await.map_err(|e| e.to_string())?; @@ -621,6 +664,7 @@ async fn run_terminal_tool( network: network_request_to_sandbox_network_access(&effective.network), allow_fs_write: effective.allow_fs_write_all, is_local: is_local_project, + wsl_zed_release: wsl_zed_release.clone(), }; // The viability check runs a brief probe subprocess, so do it off @@ -637,10 +681,9 @@ async fn run_terminal_tool( let mut retries = 0usize; loop { let probe_wrap = wrap.clone(); - let probe_cwd = working_dir.clone(); let error = match cx .background_executor() - .spawn(async move { probe_wrap.can_create_sandbox(probe_cwd.as_deref()) }) + .spawn(async move { probe_wrap.can_create_sandbox() }) .await { Ok(()) => break Some(wrap), @@ -686,10 +729,9 @@ async fn run_terminal_tool( #[cfg(not(target_os = "linux"))] { let probe_wrap = wrap.clone(); - let probe_cwd = working_dir.clone(); match cx .background_executor() - .spawn(async move { probe_wrap.can_create_sandbox(probe_cwd.as_deref()) }) + .spawn(async move { probe_wrap.can_create_sandbox() }) .await { Ok(()) => Some(wrap), diff --git a/crates/agent_settings/src/agent_profile.rs b/crates/agent_settings/src/agent_profile.rs index ca448ca85710aa..1283dbf1ea1508 100644 --- a/crates/agent_settings/src/agent_profile.rs +++ b/crates/agent_settings/src/agent_profile.rs @@ -7,7 +7,7 @@ use fs::Fs; use gpui::{App, SharedString}; use settings::{ AgentProfileContent, ContextServerPresetContent, LanguageModelSelection, Settings as _, - SettingsContent, update_settings_file, + SettingsContent, SettingsStore, update_settings_file, }; use util::ResultExt as _; @@ -116,6 +116,32 @@ impl AgentProfileSettings { self.tools.get(tool_name) == Some(&true) } + /// Whether the built-in profile with the given id still matches the shipped + /// default — i.e. the user has neither customized the built-in profile nor + /// shadowed it with a custom profile of the same id. Custom profile ids are + /// never considered unmodified defaults. + pub fn is_unmodified_default(profile_id: &AgentProfileId, cx: &App) -> bool { + if !builtin_profiles::is_builtin(profile_id) { + return false; + } + let store = cx.global::(); + let profile_in = |content: &SettingsContent| { + content + .agent + .as_ref() + .and_then(|agent| agent.profiles.as_ref()) + .and_then(|profiles| profiles.get(profile_id.as_str())) + .cloned() + }; + match ( + profile_in(store.merged_settings()), + profile_in(store.raw_default_settings()), + ) { + (Some(merged), Some(default)) => merged == default, + _ => false, + } + } + pub fn is_context_server_tool_enabled(&self, server_id: &str, tool_name: &str) -> bool { self.context_servers .get(server_id) @@ -247,4 +273,37 @@ mod tests { assert!(!profile.is_context_server_tool_enabled("server", "other_tool")); assert!(!profile.is_context_server_tool_enabled("other_server", "any_tool")); } + + #[gpui::test] + fn unmodified_default_detection(cx: &mut gpui::App) { + use gpui::UpdateGlobal as _; + + let store = SettingsStore::test(cx); + cx.set_global(store); + project::DisableAiSettings::register(cx); + AgentSettings::register(cx); + + let write = AgentProfileId(builtin_profiles::WRITE.into()); + let minimal = AgentProfileId(builtin_profiles::MINIMAL.into()); + let custom = AgentProfileId("custom".into()); + + // Fresh defaults: the shipped built-in profiles are unmodified. + assert!(AgentProfileSettings::is_unmodified_default(&write, cx)); + assert!(AgentProfileSettings::is_unmodified_default(&minimal, cx)); + // Custom (non-built-in) ids are never considered unmodified defaults. + assert!(!AgentProfileSettings::is_unmodified_default(&custom, cx)); + + // The user customizes the `write` profile; `minimal` stays untouched. + SettingsStore::update_global(cx, |store, cx| { + store + .set_user_settings( + r#"{ "agent": { "profiles": { "write": { "name": "Write", "tools": { "fetch": false } } } } }"#, + cx, + ) + .unwrap(); + }); + + assert!(!AgentProfileSettings::is_unmodified_default(&write, cx)); + assert!(AgentProfileSettings::is_unmodified_default(&minimal, cx)); + } } diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index a101920102b670..43b10a4bd0074d 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -244,6 +244,17 @@ impl ProfileProvider for Entity { fn model_selected(&self, cx: &App) -> bool { self.read(cx).model().is_some() } + + fn is_restricted(&self, cx: &App) -> bool { + project::trusted_worktrees::TrustedWorktrees::has_restricted_worktrees( + &self.read(cx).project().read(cx).worktree_store(), + cx, + ) + } + + fn profile_downgraded(&self, cx: &App) -> bool { + self.read(cx).profile_was_downgraded() + } } #[derive(Default)] diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index b81340e7cc5ddc..16b6b60875d1c6 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -4645,7 +4645,7 @@ impl ThreadView { // Sandboxed by settings, but disabled for this thread: show the // settings scope (greyed) for context above the disabled status. (ThreadSandbox::Sandboxed(settings_policy), ThreadSandbox::Unsandboxed) => { - let settings = augment_settings_sandbox_policy(settings_policy, baseline); + let settings = augment_settings_sandbox_policy(&settings_policy, baseline); SandboxStatusTooltip::disabled_for_thread(sandbox_section( "Defined in your settings:", &settings, @@ -4656,10 +4656,11 @@ impl ThreadView { ThreadSandbox::Sandboxed(settings_policy), ThreadSandbox::Sandboxed(thread_policy), ) => { - let settings = augment_settings_sandbox_policy(settings_policy, baseline); + let settings = augment_settings_sandbox_policy(&settings_policy, baseline); + let thread = SandboxPolicyDisplay::from_policy(&thread_policy); // Omit the per-thread section when it grants nothing extra. - let thread = (!sandbox_policy_grants_nothing(&thread_policy)) - .then(|| sandbox_section("Allowed for this thread:", &thread_policy, false)); + let thread = (!sandbox_policy_grants_nothing(&thread)) + .then(|| sandbox_section("Allowed for this thread:", &thread, false)); SandboxStatusTooltip::enabled( sandbox_section("Defined in your settings:", &settings, true), thread, @@ -5604,6 +5605,83 @@ impl Render for TokenUsageTooltip { } } +/// A display-ready snapshot of a sandbox policy for the status tooltip. +/// +/// The opaque `HostFilesystemLocation`s in a policy are stringified up front, +/// when this is built, so the tooltip state (which outlives the build and is +/// captured by the lazy tooltip closure) never holds the locations' fds open. +#[derive(Clone)] +struct SandboxPolicyDisplay { + fs: SandboxFsDisplay, + network: SandboxNetPolicy, + git: SandboxGitDisplay, +} + +/// The filesystem write-access portion of a [`SandboxPolicyDisplay`]. +#[derive(Clone)] +enum SandboxFsDisplay { + Unrestricted, + Restricted(Vec), +} + +/// A single writable entry to display in the sandbox tooltip: either a real host +/// location (already stringified for display) or the Linux-only host-isolated +/// `/tmp` overlay, which has no backing host path and is purely a label. +#[derive(Clone)] +enum WritableEntryDisplay { + Path(String), + // Only ever constructed on Linux (the bwrap `--tmpfs /tmp` overlay), so the + // variant is gated to match and avoid dead-code warnings elsewhere. + #[cfg(target_os = "linux")] + IsolatedTmp, +} + +/// The Git-access portion of a [`SandboxPolicyDisplay`]: whether `.git` writes +/// are granted, and the (display-only) `.git` directories the policy governs. +#[derive(Clone)] +struct SandboxGitDisplay { + allowed: bool, + git_dirs: Vec, +} + +impl SandboxPolicyDisplay { + /// Display a policy verbatim (used for the per-thread overrides, which carry + /// no implicit baseline grants). Takes the policy by reference and stringifies + /// its locations immediately, so no fd is retained past this call. + fn from_policy(policy: &SandboxPolicy) -> Self { + let fs = match &policy.fs { + SandboxFsPolicy::Unrestricted => SandboxFsDisplay::Unrestricted, + SandboxFsPolicy::Restricted { writable_paths } => SandboxFsDisplay::Restricted( + writable_paths + .iter() + .map(|location| { + WritableEntryDisplay::Path(location.untrusted_path_display().to_string()) + }) + .collect(), + ), + }; + SandboxPolicyDisplay { + fs, + network: policy.network.clone(), + git: SandboxGitDisplay::from_policy(&policy.git), + } + } +} + +impl SandboxGitDisplay { + /// Stringify a Git policy's `.git` directories for display, retaining no fds. + fn from_policy(git: &GitSandboxPolicy) -> Self { + SandboxGitDisplay { + allowed: git.allows_writes(), + git_dirs: git + .git_dirs() + .iter() + .map(|location| location.untrusted_path_display().to_string()) + .collect(), + } + } +} + /// Fold the always-granted baseline writable paths (the project's worktree /// roots, derived from the same source the terminal tool uses) and, on Linux, /// the host-isolated `/tmp` overlay into a settings policy for display. These @@ -5612,27 +5690,51 @@ impl Render for TokenUsageTooltip { /// section rather than stored. A no-op when the fs is unrestricted (rendered as /// "All paths"), since there's nothing to scope. fn augment_settings_sandbox_policy( - mut policy: SandboxPolicy, + policy: &SandboxPolicy, baseline: Vec, -) -> SandboxPolicy { - if let SandboxFsPolicy::Restricted { writable_paths } = &mut policy.fs { - let mut merged = baseline; - for path in writable_paths.drain(..) { - if !merged.contains(&path) { - merged.push(path); +) -> SandboxPolicyDisplay { + let fs = match &policy.fs { + SandboxFsPolicy::Unrestricted => SandboxFsDisplay::Unrestricted, + SandboxFsPolicy::Restricted { writable_paths } => { + // Dedup by display string. We deliberately don't open the locations' + // fds to dedup by inode here: this is a display-only tooltip and the + // string is the location's identity for that purpose. The string can + // only diverge from the captured inode while a symlink-swap is + // actively in progress, and in that case the bind validator refuses + // to run the command at all (see the `sandbox` crate) — so showing the + // requested path is always safe, and not worth a blocking syscall on + // the render path. + let mut merged: Vec = Vec::new(); + let baseline_paths = baseline.iter().map(|path| path.display().to_string()); + let granted_paths = writable_paths + .iter() + .map(|location| location.untrusted_path_display().to_string()); + for path in baseline_paths.chain(granted_paths) { + if !merged.contains(&path) { + merged.push(path); + } } + // `mut` is only needed on Linux, where the isolated `/tmp` entry is + // pushed below. + #[cfg_attr(not(target_os = "linux"), allow(unused_mut))] + let mut entries: Vec = + merged.into_iter().map(WritableEntryDisplay::Path).collect(); + // The ephemeral, host-isolated tmpfs at /tmp is Linux-specific (the + // bwrap `--tmpfs /tmp` overlay). It's a display-only label, not a + // real host path, so it can't be a captured location. + #[cfg(target_os = "linux")] + entries.push(WritableEntryDisplay::IsolatedTmp); + SandboxFsDisplay::Restricted(entries) } - // The ephemeral, host-isolated tmpfs at /tmp is Linux-specific (the - // bwrap `--tmpfs /tmp` overlay). It's a display-only label, not a real - // host path, so it can't come from the path source above. - #[cfg(target_os = "linux")] - merged.push(PathBuf::from("/tmp (isolated)")); - *writable_paths = merged; - } - policy + }; + SandboxPolicyDisplay { + fs, + network: policy.network.clone(), + git: SandboxGitDisplay::from_policy(&policy.git), + } } -fn sandbox_section(title: &str, policy: &SandboxPolicy, show_empty: bool) -> SandboxSection { +fn sandbox_section(title: &str, policy: &SandboxPolicyDisplay, show_empty: bool) -> SandboxSection { let write_empty = fs_grants_nothing(&policy.fs); let network_empty = network_grants_nothing(&policy.network); let git_empty = git_grants_nothing(&policy.git); @@ -5659,7 +5761,7 @@ fn sandbox_section(title: &str, policy: &SandboxPolicy, show_empty: bool) -> San /// Whether a policy grants nothing worth surfacing, used to decide whether to /// show the per-thread overrides section at all. -fn sandbox_policy_grants_nothing(policy: &SandboxPolicy) -> bool { +fn sandbox_policy_grants_nothing(policy: &SandboxPolicyDisplay) -> bool { fs_grants_nothing(&policy.fs) && network_grants_nothing(&policy.network) && git_grants_nothing(&policy.git) @@ -5667,24 +5769,26 @@ fn sandbox_policy_grants_nothing(policy: &SandboxPolicy) -> bool { /// Git access grants nothing to surface unless `.git` writes are allowed *and* /// at least one `.git` directory is known. -fn git_grants_nothing(git: &GitSandboxPolicy) -> bool { - !git.allows_writes() || git.git_dirs().is_empty() +fn git_grants_nothing(git: &SandboxGitDisplay) -> bool { + !git.allowed || git.git_dirs.is_empty() } /// Rows for the Git-access group: one row per writable `.git` directory (these /// may live outside the project for a linked worktree). -fn sandbox_git_rows(git: &GitSandboxPolicy) -> Vec { - match git { - GitSandboxPolicy::Allowed { git_dirs } if !git_dirs.is_empty() => git_dirs - .iter() - .map(|path| SandboxRow::git(path.clone())) - .collect(), - _ => Vec::new(), +fn sandbox_git_rows(git: &SandboxGitDisplay) -> Vec { + if !git.allowed { + return Vec::new(); } + git.git_dirs + .iter() + // The display string was captured up front; reconstruct a throwaway path + // here purely to render a label + icon (never used as an identity). + .map(|dir| SandboxRow::git(PathBuf::from(dir))) + .collect() } -fn fs_grants_nothing(fs: &SandboxFsPolicy) -> bool { - matches!(fs, SandboxFsPolicy::Restricted { writable_paths } if writable_paths.is_empty()) +fn fs_grants_nothing(fs: &SandboxFsDisplay) -> bool { + matches!(fs, SandboxFsDisplay::Restricted(entries) if entries.is_empty()) } fn network_grants_nothing(network: &SandboxNetPolicy) -> bool { @@ -5697,15 +5801,22 @@ fn network_grants_nothing(network: &SandboxNetPolicy) -> bool { /// Rows for the write-access group: a message for the "all"/"none" cases, or one /// row per granted path. -fn sandbox_fs_rows(fs: &SandboxFsPolicy) -> Vec { +fn sandbox_fs_rows(fs: &SandboxFsDisplay) -> Vec { match fs { - SandboxFsPolicy::Unrestricted => vec![SandboxRow::message("All paths (unrestricted)")], - SandboxFsPolicy::Restricted { writable_paths } if writable_paths.is_empty() => { + SandboxFsDisplay::Unrestricted => vec![SandboxRow::message("All paths (unrestricted)")], + SandboxFsDisplay::Restricted(entries) if entries.is_empty() => { vec![SandboxRow::message("None")] } - SandboxFsPolicy::Restricted { writable_paths } => writable_paths + SandboxFsDisplay::Restricted(entries) => entries .iter() - .map(|path| SandboxRow::path(path.clone())) + .map(|entry| match entry { + // The display string was captured up front; see `sandbox_git_rows`. + WritableEntryDisplay::Path(path) => SandboxRow::path(PathBuf::from(path)), + #[cfg(target_os = "linux")] + WritableEntryDisplay::IsolatedTmp => { + SandboxRow::path(PathBuf::from("/tmp (isolated)")) + } + }) .collect(), } } @@ -8265,27 +8376,23 @@ impl ThreadView { }), ) .when(has_host_list && is_open, |this| { - this.child( - v_flex() - .id(("sandbox-network-hosts-list", entry_ix)) - .max_h_40() - .overflow_y_scroll() - .children(hosts.iter().enumerate().map(|(host_ix, host)| { - h_flex() - .min_w_0() - .px_2() - .py_1p5() - .bg(cx.theme().colors().editor_background) - .when(host_ix < hosts.len() - 1, |this| { - this.border_b_1().border_color(cx.theme().colors().border) - }) - .child( - Label::new(host.clone()) - .size(LabelSize::XSmall) - .buffer_font(cx), - ) - })), - ) + this.child(v_flex().children(hosts.iter().enumerate().map( + |(host_ix, host)| { + h_flex() + .min_w_0() + .px_2() + .py_1p5() + .bg(cx.theme().colors().editor_background) + .when(host_ix < hosts.len() - 1, |this| { + this.border_b_1().border_color(cx.theme().colors().border) + }) + .child( + Label::new(host.clone()) + .size(LabelSize::XSmall) + .buffer_font(cx), + ) + }, + ))) }) }); @@ -8368,21 +8475,17 @@ impl ThreadView { }), ) .when(has_path_list && is_open, |this| { - this.child( - v_flex() - .id(("sandbox-authorization-paths-list", entry_ix)) - .max_h_40() - .overflow_y_scroll() - .children(paths.iter().enumerate().map(|(path_ix, path)| { - self.render_sandbox_authorization_path_row( - entry_ix, - path_ix, - path, - path_ix < paths.len() - 1, - cx, - ) - })), - ) + this.child(v_flex().children(paths.iter().enumerate().map( + |(path_ix, path)| { + self.render_sandbox_authorization_path_row( + entry_ix, + path_ix, + path, + path_ix < paths.len() - 1, + cx, + ) + }, + ))) }) }); @@ -8439,9 +8542,9 @@ impl ThreadView { v_flex() .border_t_1() .border_color(self.tool_card_border_color(cx)) + .children(git_access_section) .children(network_section) .children(write_section) - .children(git_access_section) .children(unsandboxed_section) .children(reason_section) .into_any_element() diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index 00f989b50134ca..3007b5fa3eb336 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -18,8 +18,9 @@ use std::{ }; use ui::{ DocumentationAside, HighlightedLabel, KeyBinding, LabelSize, ListItem, ListItemSpacing, - PopoverMenuHandle, Tooltip, prelude::*, + PopoverMenuHandle, TintColor, Tooltip, prelude::*, }; +use workspace::ToggleWorktreeSecurity; /// Trait for types that can provide and manage agent profiles pub trait ProfileProvider { @@ -34,6 +35,22 @@ pub trait ProfileProvider { /// Check if there is a model selected in the current context. fn model_selected(&self, cx: &App) -> bool; + + /// Whether the current workspace is restricted (has untrusted worktrees). + /// + /// In a restricted workspace, profiles that enable tools forbidden in + /// restricted mode are flagged, and the active built-in `write`/`ask` + /// profiles are downgraded to `minimal`. + fn is_restricted(&self, _cx: &App) -> bool { + false + } + + /// Whether the active profile has been downgraded to `minimal` because the + /// workspace is restricted (i.e. the user selected `write`/`ask`, but those + /// profiles aren't honored while restricted). + fn profile_downgraded(&self, _cx: &App) -> bool { + false + } } pub struct ProfileSelector { @@ -188,9 +205,23 @@ impl Render for ProfileSelector { IconName::ChevronDown }; + // Warn when the active profile is affected by a restricted workspace: + // either it was downgraded to `minimal`, or it still enables tools that + // are forbidden while restricted. + let show_warning = self.provider.is_restricted(cx) + && (self.provider.profile_downgraded(cx) + || !ProfilePickerDelegate::restricted_forbidden_tools(&profile_id, cx).is_empty()); + let trigger_button = Button::new("profile-selector", selected_profile) .label_size(LabelSize::Small) .color(Color::Muted) + .when(show_warning, |this| { + this.start_icon( + Icon::new(IconName::Warning) + .size(IconSize::XSmall) + .color(Color::Warning), + ) + }) .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)); let tooltip: Box AnyView> = Box::new(Tooltip::element({ @@ -339,6 +370,22 @@ impl ProfilePickerDelegate { .collect() } + /// Tools enabled by a profile that are forbidden while the workspace is + /// restricted. Returns an empty list for profiles that are safe to use. + fn restricted_forbidden_tools(profile_id: &AgentProfileId, cx: &App) -> Vec { + let Some(profile) = AgentSettings::get_global(cx).profiles.get(profile_id) else { + return Vec::new(); + }; + profile + .tools + .iter() + .filter(|(name, enabled)| { + **enabled && !agent::tool_allowed_in_restricted_mode(name.as_ref()) + }) + .map(|(name, _)| SharedString::from(name.to_string())) + .collect() + } + fn documentation(candidate: &ProfileCandidate) -> Option<&'static str> { match candidate.id.as_str() { builtin_profiles::WRITE => Some("Get help to write anything."), @@ -594,10 +641,17 @@ impl PickerDelegate for ProfilePickerDelegate { let is_active = active_id == candidate.id; let has_documentation = Self::documentation(candidate).is_some(); + let has_warning = self.provider.is_restricted(cx) + && !Self::restricted_forbidden_tools(&candidate.id, cx).is_empty(); + // The warning details are merged into the documentation aside, + // so hovering either the row or the icon shows a single popup. + let track_hover = has_documentation || has_warning; + let has_end_slot = is_active || has_warning; + Some( div() .id(("profile-picker-item", ix)) - .when(has_documentation, |this| { + .when(track_hover, |this| { this.on_hover(cx.listener(move |picker, hovered, _, cx| { if *hovered { picker.delegate.hovered_index = Some(ix); @@ -616,11 +670,23 @@ impl PickerDelegate for ProfilePickerDelegate { candidate.name.clone(), entry.positions.clone(), )) - .when(is_active, |this| { + .when(has_end_slot, |this| { this.end_slot( - div() + h_flex() + .gap_1() .pr_2() - .child(Icon::new(IconName::Check).color(Color::Accent)), + .when(has_warning, |this| { + this.child( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) + }) + .when(is_active, |this| { + this.child( + Icon::new(IconName::Check).color(Color::Accent), + ) + }), ) }), ) @@ -644,13 +710,61 @@ impl PickerDelegate for ProfilePickerDelegate { }; let candidate = self.candidates.get(entry.candidate_index)?; - let docs_aside = Self::documentation(candidate)?.to_string(); + let description = Self::documentation(candidate).map(|docs| docs.to_string()); + let forbidden_tools = if self.provider.is_restricted(cx) { + Self::restricted_forbidden_tools(&candidate.id, cx) + } else { + Vec::new() + }; + + // Nothing to show: no description and no restricted-tool warning. + if description.is_none() && forbidden_tools.is_empty() { + return None; + } let side = documentation_aside_side(cx); Some(DocumentationAside { side, - render: Rc::new(move |_| Label::new(docs_aside.clone()).into_any_element()), + render: Rc::new(move |cx| { + v_flex() + .gap_1p5() + .when_some(description.clone(), |this, description| { + this.child(Label::new(description)) + }) + .when(!forbidden_tools.is_empty(), |this| { + this.when(description.is_some(), |this| { + this.child( + div() + .border_t_1() + .border_color(cx.theme().colors().border_variant), + ) + }) + .child( + v_flex() + .gap_0p5() + .child( + h_flex() + .gap_1() + .child( + Icon::new(IconName::Warning) + .size(IconSize::XSmall) + .color(Color::Warning), + ) + .child( + Label::new("Disabled in Restricted Mode") + .size(LabelSize::Small), + ), + ) + .children(forbidden_tools.iter().map(|tool| { + Label::new(format!("• {tool}")) + .size(LabelSize::Small) + .color(Color::Muted) + })), + ) + }) + .into_any_element() + }), }) } @@ -664,29 +778,66 @@ impl PickerDelegate for ProfilePickerDelegate { cx: &mut Context>, ) -> Option { let focus_handle = self.focus_handle.clone(); + let is_restricted = self.provider.is_restricted(cx); Some( - h_flex() + v_flex() .w_full() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .p_1p5() .child( - Button::new("configure", "Configure") - .full_width() - .style(ButtonStyle::Outlined) - .key_binding( - KeyBinding::for_action_in( - &ManageProfiles::default(), - &focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(ManageProfiles::default().boxed_clone(), cx); - }), + h_flex() + .w_full() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .p_1p5() + .child( + Button::new("configure", "Configure") + .full_width() + .style(ButtonStyle::Outlined) + .key_binding( + KeyBinding::for_action_in( + &ManageProfiles::default(), + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action( + ManageProfiles::default().boxed_clone(), + cx, + ); + }), + ), ) + .when(is_restricted, |this| { + this.child( + h_flex() + .w_full() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .p_1p5() + .child( + Button::new("restricted-mode", "Restricted Mode") + .full_width() + .style(ButtonStyle::Tinted(TintColor::Warning)) + .color(Color::Warning) + .start_icon( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) + .tooltip(Tooltip::text( + "Some tools are disabled. Click to review trust settings.", + )) + .on_click(|_, window, cx| { + window.dispatch_action( + ToggleWorktreeSecurity.boxed_clone(), + cx, + ); + }), + ), + ) + }) .into_any(), ) } diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index 5b860ba16cdd8b..df3b556d6a47ca 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -395,10 +395,21 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet
 {
-                if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
-                    snippet_json_fixed.insert(0, '[');
-                    snippet_json_fixed.push_str("\n]");
-                }
+                if let Some(keymap_validator) = &keymap_validator {
+                    if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
+                        snippet_json_fixed.insert(0, '[');
+                        snippet_json_fixed.push_str("\n]");
+                    }
 
-                let value =
-                    settings::parse_json_with_comments::(&snippet_json_fixed)?;
-                let validation_errors: Vec = keymap_validator
-                    .iter_errors(&value)
-                    .map(|err| err.to_string())
-                    .collect();
-                if !validation_errors.is_empty() {
-                    anyhow::bail!("{}", validation_errors.join("\n"));
+                    let value = settings::parse_json_with_comments::(
+                        &snippet_json_fixed,
+                    )?;
+                    let validation_errors: Vec = keymap_validator
+                        .iter_errors(&value)
+                        .map(|err| err.to_string())
+                        .collect();
+                    if !validation_errors.is_empty() {
+                        anyhow::bail!("{}", validation_errors.join("\n"));
+                    }
                 }
             }
             "debug" => {
diff --git a/crates/sandbox/Cargo.toml b/crates/sandbox/Cargo.toml
index 6a65e2a098447c..c9211dfa99f57d 100644
--- a/crates/sandbox/Cargo.toml
+++ b/crates/sandbox/Cargo.toml
@@ -55,6 +55,9 @@ serde_json = { workspace = true, optional = true }
 
 [target.'cfg(target_os = "linux")'.dependencies]
 libc.workspace = true
+# Safe wrappers for the SCM_RIGHTS fd-passing and `fstat` the bind validator
+# needs, so that code doesn't hand-roll `msghdr`/`CMSG_*`/`mem::zeroed` unsafe.
+nix = { workspace = true, features = ["fs", "socket", "uio"] }
 
 [target.'cfg(target_os = "linux")'.dev-dependencies]
 tempfile.workspace = true
diff --git a/crates/sandbox/README.md b/crates/sandbox/README.md
new file mode 100644
index 00000000000000..d26bbedd2016c1
--- /dev/null
+++ b/crates/sandbox/README.md
@@ -0,0 +1,261 @@
+# `sandbox`
+
+Cross-platform sandboxing for shell commands.
+
+## Overview
+
+This crate allows creating a `Sandbox` according to some `SandboxPolicy`. A
+`SandboxPolicy` expresses:
+- what filesystem operations are allowed
+- which kinds of networking operations are allowed
+- whether git metadata is protected
+
+Once you have a `Sandbox`, you can use it to run commands that are constrained
+by that policy.
+
+## Security model
+
+The sandbox itself assumes all untrusted code is maximally hostile. It does
+*not* assume that the untrusted code is written by a
+well-meaning-but-perhaps-marginally-unaligned AI agent.
+
+However, practical limitations make the default profile in Zed not secure
+against attacks. An attacker with read/write access to the current directory can:
+- create a new Rust project in the current dir
+- create a proc macro library containing malicious code
+- use that macro in the project somewhere
+- rust-analyzer will run that proc macro outside the sandbox
+
+This can be mitigated by:
+- disabling any language servers with the capability to run untrusted code
+- not granting git access (since write access to `.git` can similarly be
+  escalated to unsandboxed code execution via `$EDITOR` and various other
+  methods)
+
+## Implementation
+
+The implementations are highly platform-specific:
+- Mac support comes from Seatbelt
+- Linux support comes from [bubblewrap], implemented via Linux [namespaces].
+- Windows:
+  - WSL: same as Linux
+  - non-WSL: not supported
+
+Note that WSL shells can be used on all Windows projects, regardless of whether
+the files are stored in the Linux filesystem or not.
+
+## Architecture
+
+Filesystem restrictions are different on all platforms. Network restrictions
+however largely follow a similar approach (details omitted):
+- Disable networking in the sandbox, except for one localhost port
+- Within the sandbox, set `HTTP_PROXY` and friends to tell programs to
+  communicate with that socket
+- On the Zed host side, there is a proxy that listens to that port that enforces
+  domain filtering
+
+On Linux specifically, there is an intermediate socket that allows data to flow
+out of the sandbox. This is required because, unlike seatbelt, bubblewrap runs
+sandboxed programs in an entirely separate network stack (i.e. it has a
+different `localhost`).
+
+### Linux
+
+A naive implementation on Linux would work roughly like:
+- Figure out which paths are read-only and which are read/write
+- Run the sandboxed program through `bwrap` with `--ro-bind` for read-only and
+  `--bind` for read/write
+
+However, this fails because of a nasty TOCTOU.
+
+#### The nasty TOCTOU
+
+Consider the following case:
+- an attacker has convinced the user to open `project`, which contains an evil
+  `AGENTS.md`
+- They have also convinced the user to allow git access
+- This means that the user will have given the following permissions to the sandbox:
+  - read/write access to `project`
+  - read/write access to `project/.git`
+  - read/write access to an isolated `/tmp`
+  - read-only access to `/`
+- The `AGENTS.md` instructs the LLM to do the following:
+  - spawn two subagents
+  - the first subagent tries to swap `project/.git` with a symlink to
+    `/home/alice` [`renameat2(2)`][renameat2] with the `RENAME_EXCHANGE`
+    flag set
+  - the second subagent tries to run `echo 'export PATH="proj/obfuscated.../evil_eavesdropping_sudo/bin:$PATH"' >> proj/.git/.bashrc`
+- The user sends a prompt, we pick up the evil `AGENTS.md` instructions, and the
+  agent does them
+- Zed checks whether paths are symlinks outside the allowable paths before
+  passing them to bubblewrap, but there is a **time delay** between this check
+  and when bubblewrap mounts them.
+- In this delay, the `renameat2` may succeed, which means that:
+  - At check time, `proj/.git` is a subdirectory of `proj`
+  - At bind time, `proj/.git` is a symlink to `/home/alice`
+- The attacker is now running code in a sandbox which has **read/write** access
+  to `/home/alice`, and so the second command to inject the malicious
+  credential-stealing sudo succeeds.
+
+
+```mermaid
+sequenceDiagram
+    participant Agent as Zed Agent
+    participant S1 as Subagent 1 swapper
+    participant S2 as Subagent 2 writer
+    participant Zed as Zed path validation
+    participant BW as bubblewrap
+
+    Note over Agent: Evil AGENTS.md picked up, git access granted
+    Note over Agent: Grants rw project, rw project/.git, rw /tmp, ro /
+    Agent->>S1: spawn swap project/.git for a symlink to /home/alice
+    Agent->>S2: spawn append PATH hijack to project/.git/.bashrc
+    Zed->>Zed: check project/.git is not an out-of-bounds symlink
+    Note over Zed: at check time it is a real subdirectory, so OK
+    Note over Zed,BW: time delay, time-of-check to time-of-use
+    S1->>S1: renameat2 RENAME_EXCHANGE wins the race
+    Note over S1: project/.git is now a symlink to /home/alice
+    Zed->>BW: bind project/.git into the sandbox
+    BW->>BW: re-resolve project/.git, following symlink to /home/alice
+    S2->>BW: write project/.git/.bashrc
+    BW-->>S2: write lands in /home/alice/.bashrc
+    Note over S2: escalation, project-scoped grant becomes arbitrary write
+```
+
+Note that this attack requires *two nested directories*, each with read/write
+grants. A single grant is insufficient, because you must mutate *a path which is
+used as a `--bind` argument*. If you cannot mutate a parent (because we are
+assuming no nested directories), then the only part you can mutate is the the
+read/write grant path itself (i.e. `/home/alice/project`). But, in bubblewrap's
+model, doing this requires write access to the *parent* (i.e. `/home/alice`),
+which we have assumed is not present.
+
+`./bind_source_toctou_test.sh` is a small bash script demonstrating this
+behaviour. It tries to replace the current dir with a symlink, and it fails due
+to invalid permissions.
+
+#### The naive (and incorrect) fix
+
+It is tempting to read the previous paragraph and think "that's simple, just
+disallow nested directories". In theory, this would work. A read/write grant to `/foo` and `/foo/bar` is logically equivalent to a read/write grant to just `/foo`. And the following is *true*: 
+
+> If there is no pair of read/write grants such that one is an ancestor of the
+> other, this TOCTOU attack is impossible.
+
+However, this is not a viable countermeasure for two reasons:
+
+1. It requires that no two grants of this kind ever exist at the same time
+   *globally across the whole system*. For example, opening `/foo` in one zed
+   window and `/foo/bar` in another would re-open this exploit. Even if we did
+   mitigate this by widening `/foo/bar` to have access to `/foo` (which in
+   itself is an unacceptable privilege escalation), we still wouldn't be able to
+   control non-Zed processes.
+2. It prevents the potentially useful pattern of:
+  - read/write access to `/foo`
+  - read-only access to `/foo/bar`
+  - read/write access to `/foo/bar/baz`
+
+Because of this, we need something more robust.
+
+#### The correct fix
+
+The correct fix involves using file descriptors as the source of truth, rather
+than paths. This is important because file descriptors are stable once opened,
+regardless of what happens to the path. The symlink swap attack will not change
+which inode the FD points to.
+
+This leads to a different question: how do we tell `bwrap` to use FDs instead of paths?
+
+`bwrap` does support `--bind-fd`, but this has another issue: "how do you get
+FDs into the `bwrap` process?
+
+There are two options:
+1. open the FDs in zed, clear `CLOEXEC`, then fork/exec into bwrap with the FD arguments
+2. send them into a helper process inside the sandbox using an `SCM_RIGHTS`
+  socket, and validate from the inside of the sandbox.
+
+We chose option 2 because we already have a helper process inside the sandbox
+(to set up the HTTP proxy).
+
+The flow for this approach in detail is:
+- open each *writable* path we `--bind` and get an `O_PATH` FD (which pins the
+  inode without granting read/write on its contents)
+- create an `SCM_RIGHTS` socket over which we can send the FDs
+- run `bwrap --bind /path1 /path1 ... -- zed --zed-linux-sandbox-launcher `
+  - note: we use (potentially swapped) paths
+  - we also mount the socket in the sandbox
+- the sandbox bridge reads the FDs from the socket, does the following for each
+  read/write bind:
+  - `fstat` the FD to get the `(device, inode)`
+  - `lstat` the corresponding mount path to get its `(device, inode)`
+  - check that they match
+
+  Note that this is essentially the check that `bwrap --bind-fd` does internally.
+- if all binds match, run the untrusted command, otherwise refuse to execute
+
+```mermaid
+sequenceDiagram
+    participant Zed as Zed host
+    participant BW as bubblewrap
+    participant Bridge as sandbox-bridge in sandbox
+    participant Prog as untrusted program
+
+    Zed->>Zed: open O_PATH FD per writable path, pinning the inode
+    Zed->>Zed: create SCM_RIGHTS socket
+    Zed->>BW: exec bwrap, binding paths, then zed --zed-linux-sandbox-launcher
+    Note over Zed,BW: binds use possibly-swapped paths, socket mounted in sandbox
+    Zed->>Bridge: send FDs over the SCM_RIGHTS socket
+    loop each writable bind
+        Bridge->>Bridge: fstat the FD to get device and inode
+        Bridge->>Bridge: lstat the mount path to get device and inode
+        Bridge->>Bridge: compare the two pairs
+    end
+    alt all binds match
+        Bridge->>Prog: exec the untrusted command
+    else any mismatch, a path was swapped
+        Bridge-->>Zed: refuse to execute
+    end
+```
+
+If the attacker managed to change a path to point to a different inode to when
+the FD was captured, the check will fail, and we don't run the untrusted
+command.
+
+### Windows
+
+> [!NOTE] The Windows implementation depends heavily on the details of the Linux
+  implementation. 
+
+The Linux approach works perfectly on WSL in theory (WSL uses a "regular linux
+kernel"), but there is one practical thorn: the zed host code that creates the
+FD is now running on Windows, but we need Linux file descriptors.
+
+To work around this, we launch `zed --wsl-sandbox-helper` in WSL, which is a
+shim that captures the FDs and sets up the socket. We download this to
+`~/.local/libexec/zed`, so that it does not conflict with the Windows `zed.exe`
+binary that WSL will inject into the Linux `$PATH` (yes the `.exe` is stripped).
+## Code design
+
+### `HostFilesystemLocation`
+
+As mentioned above, TOCTOUs are a real issue. MacOS is not vulnerable to the
+TOCTOU that affected Linux, but there is still a risk if we canonicalize paths
+twice with a time delay between. 
+
+To mitigate this, sensitive APIs take a `HostFilesystemLocation`. This is:
+- an `Arc` on Linux
+- a `PathBuf` on MacOS
+
+This type does not expose its inner value, and so this encourages the developer
+to capture and validate the path once, before passing it into this type.
+
+### `SandboxFilesystemLocation`
+
+A thin wrapper around a `PathBuf` representing a location *inside* the sandbox.
+No hardening is required - the worst a tampered in-sandbox path can do is expose
+already-granted host files at a different in-sandbox path.
+
+
+[bubblewrap]: https://github.com/containers/bubblewrap
+[namespaces]: https://en.wikipedia.org/wiki/Linux_namespaces
+[renameat2]: https://man.archlinux.org/man/renameat2.2.en
diff --git a/crates/sandbox/bind_source_toctou_test.sh b/crates/sandbox/bind_source_toctou_test.sh
new file mode 100755
index 00000000000000..9addfa046782d7
--- /dev/null
+++ b/crates/sandbox/bind_source_toctou_test.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+set -u
+command -v bwrap >/dev/null || { echo "bwrap not found" >&2; exit 2; }
+
+ROOT="$(mktemp -d)"; trap 'rm -rf "$ROOT"' EXIT
+READ_WRITE_DIR="$ROOT/foo/bar"
+ATTACK_TARGET="$ROOT/baz"
+mkdir -p "$READ_WRITE_DIR" "$ATTACK_TARGET"
+
+# /foo (parent of the grant) is read-only; only /foo/bar is writable, so staging
+# then renaming a symlink over /foo/bar fails.
+# 
+# Writable access to `/foo/bar` should not result in the ability to make
+# `/foo/bar` into a symlink.
+bwrap \
+  --ro-bind / / \
+  --bind "$READ_WRITE_DIR" "$READ_WRITE_DIR" \
+  --unshare-user \
+  --setenv READ_WRITE_DIR "$READ_WRITE_DIR" \
+  --setenv ATTACK_TARGET "$ATTACK_TARGET" \
+  /bin/sh -c '
+    ln -s "$ATTACK_TARGET" "$READ_WRITE_DIR.link" && mv -fT "$READ_WRITE_DIR.link" "$READ_WRITE_DIR" && exit 7
+    exit 0
+  '
+rc=$?
+
+if [ "$rc" -eq 7 ] || [ -L "$READ_WRITE_DIR" ]; then echo "FAIL: grant redirected"; exit 1; fi
+[ "$rc" -eq 0 ] || { echo "bwrap error (rc=$rc)" >&2; exit 2; }
+echo "PASS: swap blocked"
diff --git a/crates/sandbox/src/bwrap_test_helper.rs b/crates/sandbox/src/bwrap_test_helper.rs
index 27c8523ce32b54..d0aaa29a83726f 100644
--- a/crates/sandbox/src/bwrap_test_helper.rs
+++ b/crates/sandbox/src/bwrap_test_helper.rs
@@ -30,12 +30,13 @@ fn main() {
 mod imp {
     use std::io::{Read as _, Write as _};
     use std::net::TcpStream;
-    use std::path::{Path, PathBuf};
+    use std::path::Path;
     use std::time::Duration;
 
     use anyhow::{Context as _, Result, bail};
     use sandbox::{
-        CommandAndArgs, Sandbox, SandboxError, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy,
+        CommandAndArgs, GitSandboxPolicy, HostFilesystemLocation, Sandbox, SandboxError,
+        SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy,
     };
     use serde::Deserialize;
 
@@ -111,6 +112,14 @@ mod imp {
         network_access: NetMode,
         #[serde(default)]
         allowed_domains: Vec,
+        /// `.git` directories to protect (contents read-only on Linux). Selects a
+        /// `Denied` Git policy. Mutually exclusive with `gitAllowed`.
+        #[serde(default)]
+        git_disabled: Vec,
+        /// `.git` directories to make writable. Selects an `Allowed` Git policy.
+        /// Mutually exclusive with `gitDisabled`.
+        #[serde(default)]
+        git_allowed: Vec,
 
         // ---- operation (exactly one) ----
         /// Read this host path from inside the sandbox.
@@ -169,12 +178,24 @@ mod imp {
         checks.finish()
     }
 
-    fn policy_of(check: &Check) -> SandboxPolicy {
+    fn policy_of(check: &Check) -> Result {
         let fs = match check.fs {
             FsMode::Unrestricted => SandboxFsPolicy::Unrestricted,
-            FsMode::Restricted => SandboxFsPolicy::Restricted {
-                writable_paths: check.writable_paths.iter().map(PathBuf::from).collect(),
-            },
+            FsMode::Restricted => {
+                let mut writable_paths = Vec::new();
+                for path in &check.writable_paths {
+                    // Mirror production (`acp_thread::SandboxWrap::to_policy`):
+                    // the directory must exist before its inode can be pinned, so
+                    // create it up front, then capture it.
+                    std::fs::create_dir_all(path)
+                        .with_context(|| format!("failed to create writable path {path}"))?;
+                    writable_paths.push(
+                        HostFilesystemLocation::new(path)
+                            .with_context(|| format!("failed to capture writable path {path}"))?,
+                    );
+                }
+                SandboxFsPolicy::Restricted { writable_paths }
+            }
         };
         let network = match check.network_access {
             NetMode::Unrestricted => SandboxNetPolicy::Unrestricted,
@@ -183,18 +204,47 @@ mod imp {
                 allowed_domains: check.allowed_domains.clone(),
             },
         };
-        SandboxPolicy {
-            fs,
-            network,
-            git: sandbox::GitSandboxPolicy::default(),
-        }
+        // `gitAllowed` and `gitDisabled` are mutually exclusive; `gitAllowed`
+        // wins if both are (mistakenly) set. With neither, the default protects
+        // an empty set of dirs, which is a no-op.
+        let git = if !check.git_allowed.is_empty() {
+            GitSandboxPolicy::Allowed {
+                git_dirs: capture_git_dirs(&check.git_allowed),
+            }
+        } else if !check.git_disabled.is_empty() {
+            GitSandboxPolicy::Denied {
+                git_dirs: capture_git_dirs(&check.git_disabled),
+            }
+        } else {
+            GitSandboxPolicy::default()
+        };
+        Ok(SandboxPolicy { fs, network, git })
+    }
+
+    /// Capture each already-existing `.git` directory, mirroring production's
+    /// fail-closed `filter_map(HostFilesystemLocation::new(..).ok())`: a `.git`
+    /// that does not yet exist can't be pinned and is simply skipped (the
+    /// documented Linux gap). Unlike writable paths, these are never created
+    /// here — whether one exists is exactly what several checks turn on.
+    fn capture_git_dirs(paths: &[String]) -> Vec {
+        paths
+            .iter()
+            .filter_map(|path| HostFilesystemLocation::new(path).ok())
+            .collect()
     }
 
     fn describe(check: &Check) -> String {
         if let Some(name) = &check.name {
             return name.clone();
         }
-        let policy = format!("fs={:?},net={:?}", check.fs, check.network_access);
+        let git = if !check.git_allowed.is_empty() {
+            format!(",git_allowed={:?}", check.git_allowed)
+        } else if !check.git_disabled.is_empty() {
+            format!(",git_disabled={:?}", check.git_disabled)
+        } else {
+            String::new()
+        };
+        let policy = format!("fs={:?},net={:?}{git}", check.fs, check.network_access);
         let op = if let Some(path) = &check.read {
             format!("read {path}")
         } else if let Some(path) = &check.write {
@@ -211,10 +261,10 @@ mod imp {
 
     fn run_check(check: &Check, echo_port: &str, checks: &mut Checks) -> Result<()> {
         let label = describe(check);
-        let policy = policy_of(check);
 
         if let Some(expect_can_create) = check.can_create {
-            let outcome = Sandbox::can_create(&policy, None);
+            let policy = policy_of(check)?;
+            let outcome = Sandbox::can_create(&policy);
             let passed = match (&outcome, expect_can_create) {
                 (Ok(()), true) => true,
                 (Ok(()), false) => false,
@@ -234,11 +284,11 @@ mod imp {
             .with_context(|| format!("check {label:?} has an operation but no `succeeds`"))?;
 
         let actual = if let Some(path) = &check.read {
-            run_read(&policy, path)?
+            run_read(check, path)?
         } else if let Some(path) = &check.write {
-            run_write(&policy, path)?
+            run_write(check, path)?
         } else if let Some(host) = &check.network {
-            run_network(&policy, host, echo_port)?
+            run_network(check, host, echo_port)?
         } else {
             bail!("check {label:?} has no operation");
         };
@@ -250,7 +300,7 @@ mod imp {
     /// Seed a host file, then `cat` it from inside the sandbox. Reads are always
     /// granted (root is bound read-only), so this proves the sandbox doesn't
     /// *block* reads of existing host files.
-    fn run_read(policy: &SandboxPolicy, path: &str) -> Result {
+    fn run_read(check: &Check, path: &str) -> Result {
         let path = Path::new(path);
         if let Some(parent) = path.parent() {
             std::fs::create_dir_all(parent)
@@ -259,7 +309,10 @@ mod imp {
         std::fs::write(path, b"sandbox-test\n")
             .with_context(|| format!("failed to seed readable file {}", path.display()))?;
 
-        let mut sandbox = Sandbox::new(policy.clone()).map_err(sandbox_err)?;
+        // Build the policy only after the fixtures exist: capturing a
+        // `HostFilesystemLocation` pins the inode, so the path must be present.
+        let policy = policy_of(check)?;
+        let mut sandbox = Sandbox::new(policy).map_err(sandbox_err)?;
         run_command(
             &mut sandbox,
             "sh",
@@ -271,18 +324,22 @@ mod imp {
     /// the command exited 0 *and* the bytes actually landed on the host file —
     /// a write that only hits the sandbox's ephemeral tmpfs counts as blocked,
     /// since it never escaped the sandbox.
-    fn run_write(policy: &SandboxPolicy, path: &str) -> Result {
+    fn run_write(check: &Check, path: &str) -> Result {
         let path = Path::new(path);
         if let Some(parent) = path.parent() {
             // Create the parent on the host so the only thing under test is the
-            // sandbox's write permission, not a missing directory.
+            // sandbox's write permission, not a missing directory. This also
+            // makes a `.git` parent exist before the policy captures it.
             std::fs::create_dir_all(parent)
                 .with_context(|| format!("failed to create parent of {}", path.display()))?;
         }
         // Start from a clean slate so `exists()` afterwards is meaningful.
         let _ = std::fs::remove_file(path);
 
-        let mut sandbox = Sandbox::new(policy.clone()).map_err(sandbox_err)?;
+        // Build the policy only after the fixtures exist: capturing a
+        // `HostFilesystemLocation` pins the inode, so the path must be present.
+        let policy = policy_of(check)?;
+        let mut sandbox = Sandbox::new(policy).map_err(sandbox_err)?;
         let command_ok = run_command(
             &mut sandbox,
             "sh",
@@ -297,14 +354,15 @@ mod imp {
     /// Connect to `host` (`hostname` or `hostname:port`) from inside the
     /// sandbox via the `__echo_check` subcommand, which honors `HTTP_PROXY` for
     /// the restricted-network case.
-    fn run_network(policy: &SandboxPolicy, host: &str, echo_port: &str) -> Result {
+    fn run_network(check: &Check, host: &str, echo_port: &str) -> Result {
         let target = if host.contains(':') {
             host.to_string()
         } else {
             format!("{host}:{echo_port}")
         };
         let exe = current_exe_str()?;
-        let mut sandbox = Sandbox::new(policy.clone()).map_err(sandbox_err)?;
+        let policy = policy_of(check)?;
+        let mut sandbox = Sandbox::new(policy).map_err(sandbox_err)?;
         run_command(&mut sandbox, &exe, &[SUBCOMMAND_ECHO_CHECK, &target])
     }
 
diff --git a/crates/sandbox/src/linux_bubblewrap.rs b/crates/sandbox/src/linux_bubblewrap.rs
index 781a90f84df889..18e445e02babeb 100644
--- a/crates/sandbox/src/linux_bubblewrap.rs
+++ b/crates/sandbox/src/linux_bubblewrap.rs
@@ -23,21 +23,41 @@
 //! eliminates a race condition involving BPF user notifications.
 
 use anyhow::{Context as _, Result, anyhow, bail};
-use std::ffi::OsString;
+use std::ffi::{OsStr, OsString};
 use std::io::{Read, Write};
 use std::net::{Ipv4Addr, Shutdown, TcpListener, TcpStream};
+use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd, RawFd};
 use std::os::unix::fs::MetadataExt as _;
-use std::os::unix::net::UnixStream;
-use std::os::unix::process::ExitStatusExt as _;
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::os::unix::process::{CommandExt as _, ExitStatusExt as _};
 use std::path::{Path, PathBuf};
 use std::process::{Command, Stdio};
-use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
 use std::thread;
 
-const BRIDGE_FLAG: &str = "--zed-linux-sandbox-bridge";
+/// Re-exec marker for the in-sandbox launcher: it runs inside the sandbox before
+/// the real command to (a) validate that bwrap bound the writable grants to the
+/// inodes we captured (the bind-source TOCTOU backstop) and (b) run the
+/// restricted-network HTTP bridge. See `README.md` for the design.
+const LAUNCHER_FLAG: &str = "--zed-linux-sandbox-launcher";
+/// Re-exec marker for the WSL-side helper. This runs *inside WSL* (a Linux
+/// process) and does what `Sandbox::wrap` + the validation-fd sender do
+/// in-process on native Linux: capture the writable binds' `O_PATH` fds, stand
+/// up the validation socket, assemble the bwrap invocation, and spawn it. The
+/// capture must happen WSL-side because a Windows process holds no Linux fds.
+/// See `README.md`. Shared with the Windows side via `crate::WSL_SANDBOX_HELPER_FLAG`.
+const WSL_HELPER_FLAG: &str = crate::WSL_SANDBOX_HELPER_FLAG;
+/// Sentinel argv token meaning "this optional field is absent".
+const LAUNCHER_NONE: &str = "-";
 const PROXY_SOCKET_SANDBOX_PATH_PREFIX: &str = "/tmp/zed-sandbox";
+const VALIDATION_SOCKET_SANDBOX_PATH_PREFIX: &str = "/tmp/zed-sandbox-validate";
 const SANDBOX_SETUP_FAILED_EXIT_CODE: i32 = 126;
 const PUMP_BUFFER_SIZE: usize = 64 * 1024;
+/// Upper bound on writable binds validated in a single `SCM_RIGHTS` message,
+/// kept comfortably below the kernel's per-message fd limit (`SCM_MAX_FD`, 253).
+/// Exceeding it fails closed rather than silently validating a subset.
+const MAX_VALIDATED_BINDS: usize = 200;
 
 /// Network-access setting for a sandboxed command.
 ///
@@ -157,16 +177,40 @@ fn is_setuid_root(path: &Path) -> bool {
     reason = "the probe is a short-lived background operation that must block on bwrap"
 )]
 fn probe_bwrap(bwrap: &Path, bwrap_args: &[String]) -> bool {
-    Command::new(bwrap)
+    // Capture stderr (rather than discarding it) so a failed probe can report
+    // *why* bwrap refused — the difference between "user namespaces disabled",
+    // "chdir target missing", "no permission", etc. — instead of a bare
+    // `SandboxProbeFailed`.
+    let output = Command::new(bwrap)
         .args(bwrap_args)
         .arg("--")
         .arg("true")
         .stdin(Stdio::null())
         .stdout(Stdio::null())
-        .stderr(Stdio::null())
-        .status()
-        .map(|status| status.success())
-        .unwrap_or(false)
+        .stderr(Stdio::piped())
+        .output();
+
+    match output {
+        Ok(output) if output.status.success() => true,
+        Ok(output) => {
+            log::warn!(
+                "[sandbox] bwrap probe failed ({}). command: {} {}\nbwrap stderr: {}",
+                output.status,
+                bwrap.display(),
+                bwrap_args.join(" "),
+                String::from_utf8_lossy(&output.stderr).trim()
+            );
+            false
+        }
+        Err(error) => {
+            log::warn!(
+                "[sandbox] bwrap probe could not be spawned: {error}. command: {} {}",
+                bwrap.display(),
+                bwrap_args.join(" ")
+            );
+            false
+        }
+    }
 }
 
 /// Build the `bwrap` argument list (everything after the `bwrap` program and
@@ -185,23 +229,31 @@ pub fn build_bwrap_args(
     let proxy_socket_sandbox_path = proxy_socket_path
         .filter(|_| matches!(permissions.network, NetworkAccess::LocalhostPort(_)))
         .map(|_| unique_proxy_socket_sandbox_path());
-    build_bwrap_args_with_proxy_socket_sandbox_path(
+    build_bwrap_args_with_sandbox_paths(
         writable_directories,
         protected_git_dirs,
         permissions,
         cwd,
         proxy_socket_path,
         proxy_socket_sandbox_path.as_deref(),
+        None,
+        None,
     )
 }
 
-fn build_bwrap_args_with_proxy_socket_sandbox_path(
+#[allow(
+    clippy::too_many_arguments,
+    reason = "a flat arg list mirrors the bwrap flags this assembles"
+)]
+fn build_bwrap_args_with_sandbox_paths(
     writable_directories: &[&Path],
     protected_git_dirs: &[&Path],
     permissions: SandboxPermissions,
     cwd: Option<&Path>,
     proxy_socket_path: Option<&Path>,
     proxy_socket_sandbox_path: Option<&Path>,
+    validation_socket_path: Option<&Path>,
+    validation_socket_sandbox_path: Option<&Path>,
 ) -> Vec {
     let mut args = Vec::new();
 
@@ -222,20 +274,20 @@ fn build_bwrap_args_with_proxy_socket_sandbox_path(
         args.push("/tmp".to_string());
 
         for directory in writable_directories {
-            // Bind each writable directory at its *exact* path. We must never
-            // fall back to an existing ancestor: that would grant write access
-            // to a broader tree than was requested and approved (e.g. binding
-            // `$HOME` for a not-yet-created `~/.cache/...`, or re-binding the
-            // host `/tmp` over the tmpfs established above). `wrap_invocation`
-            // creates the requested directories before launch so they exist
-            // here; any that still don't are skipped rather than widened.
+            // Bind each writable directory at its *exact* path, **verbatim** —
+            // never re-`canonicalize`d here. The path was already resolved once,
+            // at capture time, to the pinned inode's current path (`readlink` of
+            // the `O_PATH` fd); re-resolving it now would reopen the
+            // time-of-check-to-time-of-use gap a malicious swap exploits. We must
+            // also never widen to an existing ancestor, so a path that doesn't
+            // exist is skipped rather than falling back to a parent. Whatever
+            // inode bwrap actually binds is verified after the mounts by the
+            // in-sandbox launcher (see `validate_binds`), which fails closed on a
+            // mismatch.
             if !directory.exists() {
                 continue;
             }
-            let canonical = directory
-                .canonicalize()
-                .unwrap_or_else(|_| directory.to_path_buf());
-            let path = canonical.to_string_lossy().into_owned();
+            let path = directory.to_string_lossy().into_owned();
             push_bind(&mut args, "--bind", &path, &path);
         }
 
@@ -243,18 +295,17 @@ fn build_bwrap_args_with_proxy_socket_sandbox_path(
         // worktree binds above (order matters: later binds win). Unlike
         // Seatbelt, bwrap can't deny content reads while keeping metadata, so on
         // Linux a protected `.git` is read-only — its contents stay readable but
-        // can't be written. A not-yet-existing `.git` can't be bound, so it's
-        // skipped (a documented gap vs. macOS, which denies it even before it
-        // exists). When Git access is granted these dirs are in
-        // `writable_directories` instead and this list is empty.
+        // can't be written. A read-only re-bind needs no TOCTOU check: the whole
+        // root is already read-only, so re-exposing a path read-only grants
+        // nothing new even if its source was swapped. A not-yet-existing `.git`
+        // can't be bound, so it's skipped (a documented gap vs. macOS). When Git
+        // access is granted these dirs are in `writable_directories` instead and
+        // this list is empty.
         for git_dir in protected_git_dirs {
             if !git_dir.exists() {
                 continue;
             }
-            let canonical = git_dir
-                .canonicalize()
-                .unwrap_or_else(|_| git_dir.to_path_buf());
-            let path = canonical.to_string_lossy().into_owned();
+            let path = git_dir.to_string_lossy().into_owned();
             push_bind(&mut args, "--ro-bind", &path, &path);
         }
     }
@@ -285,6 +336,17 @@ fn build_bwrap_args_with_proxy_socket_sandbox_path(
         NetworkAccess::All => {}
     }
 
+    // The validation socket is filesystem-based, so it works regardless of the
+    // network policy (an `--unshare-net`'d sandbox can't reach an abstract
+    // socket, but a bind-mounted one is fine). Bind it after the `/tmp` tmpfs so
+    // it isn't shadowed by the overlay.
+    if let Some((source, destination)) = validation_socket_path.zip(validation_socket_sandbox_path)
+    {
+        let source = source.to_string_lossy().into_owned();
+        let destination = destination.to_string_lossy().into_owned();
+        push_bind(&mut args, "--bind", &source, &destination);
+    }
+
     if let Some(cwd) = cwd {
         args.push("--chdir".to_string());
         args.push(cwd.to_string_lossy().into_owned());
@@ -308,6 +370,160 @@ fn unique_proxy_socket_sandbox_path() -> PathBuf {
     ))
 }
 
+/// In-sandbox bind destination for the validation socket. Each sandboxed command
+/// runs in its own mount namespace, so this only needs to avoid colliding with
+/// other binds in the same namespace.
+fn unique_validation_socket_sandbox_path() -> PathBuf {
+    static COUNTER: AtomicU64 = AtomicU64::new(0);
+    let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
+    PathBuf::from(format!(
+        "{VALIDATION_SOCKET_SANDBOX_PATH_PREFIX}-{}-{counter}.sock",
+        std::process::id()
+    ))
+}
+
+/// Create a private, owner-only directory for the validation listener socket and
+/// return `(directory, socket path)`. The socket is a host path (it lives
+/// outside the sandbox's `/tmp` tmpfs) and is bind-mounted in at
+/// [`unique_validation_socket_sandbox_path`].
+///
+/// The socket carries the captured `O_PATH` descriptors over `SCM_RIGHTS`, so
+/// connect access must be confined to us. Rather than bind in shared `/tmp` and
+/// `chmod` after (which leaves a window where another user could connect before
+/// the `chmod` lands), we create a `0700` directory up front: `create` is atomic
+/// and fails if the path already exists, and `mode(0o700)` makes it
+/// owner-only from creation, so no other user can even traverse to the socket.
+fn unique_validation_socket_host_path() -> std::io::Result<(PathBuf, PathBuf)> {
+    use std::os::unix::fs::DirBuilderExt as _;
+    static COUNTER: AtomicU64 = AtomicU64::new(0);
+    let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
+    let dir = std::env::temp_dir().join(format!(
+        "zed-sandbox-validate-{}-{counter}",
+        std::process::id()
+    ));
+    // Clear a stale directory left by a previous run that reused this pid; this
+    // only succeeds for a directory we own, so it can't be used to displace
+    // another user's squatted path (in which case `create` below fails closed).
+    let _ = std::fs::remove_dir_all(&dir);
+    std::fs::DirBuilder::new().mode(0o700).create(&dir)?;
+    let socket_path = dir.join("validate.sock");
+    Ok((dir, socket_path))
+}
+
+/// Host endpoint that hands the in-sandbox validator the captured `O_PATH`
+/// descriptors for the writable binds.
+///
+/// Runs in-process: `spawn` starts a short-lived background **thread** (never a
+/// separate process) that listens on an owner-only `AF_UNIX` socket and serves
+/// the descriptors to **exactly one** client (the in-sandbox validator) via
+/// `SCM_RIGHTS`, then closes and removes the socket. The validator connects
+/// once, after bwrap has mounted the binds; `SCM_RIGHTS` keeps the descriptors
+/// alive in flight, so once the single send completes there is nothing left to
+/// serve. Holding the descriptors until that send keeps the captured inodes
+/// pinned (so they can't be recycled) up to the moment they're handed off.
+///
+/// It is owned by the per-command [`Sandbox`](crate::Sandbox); `Drop` wakes a
+/// still-blocked accept and removes the socket and its private directory (a
+/// no-op if the thread already tore them down). The socket is bind-mounted into
+/// the sandbox at [`Self::sandbox_socket_path`].
+pub(crate) struct ValidationFdSender {
+    host_socket_dir: PathBuf,
+    host_socket_path: PathBuf,
+    sandbox_socket_path: PathBuf,
+    shutdown: Arc,
+}
+
+impl ValidationFdSender {
+    /// Start serving `fds` (one per writable bind, in bind order). The caller
+    /// must pass the same order to the launcher so each fd lines up with its
+    /// bind-destination path.
+    pub(crate) fn spawn(fds: Vec) -> std::io::Result {
+        use std::os::unix::fs::PermissionsExt as _;
+
+        if fds.len() > MAX_VALIDATED_BINDS {
+            return Err(std::io::Error::other(format!(
+                "too many writable binds to validate ({} > {MAX_VALIDATED_BINDS})",
+                fds.len()
+            )));
+        }
+        let (host_socket_dir, host_socket_path) = unique_validation_socket_host_path()?;
+        let listener = UnixListener::bind(&host_socket_path)?;
+        // The enclosing directory is already `0700`, so this is defense in depth:
+        // connect permission on an `AF_UNIX` socket is governed by write
+        // permission on the socket file, so keep it owner-only too.
+        std::fs::set_permissions(&host_socket_path, std::fs::Permissions::from_mode(0o600))?;
+        let sandbox_socket_path = unique_validation_socket_sandbox_path();
+        let shutdown = Arc::new(AtomicBool::new(false));
+
+        // We use std threading APIs here because this code is run from both the
+        // Linux zed binary and also the WSL helper, which does not have a GPUI
+        // runtime.
+        thread::Builder::new()
+            .name("zed-sandbox-validation".to_string())
+            .spawn({
+                let shutdown = shutdown.clone();
+                let host_socket_path = host_socket_path.clone();
+                move || {
+                    // `fds` is held until the single send below, keeping the
+                    // pinned inodes alive until they're handed to the validator.
+                    let raw_fds: Vec = fds.iter().map(|fd| fd.as_raw_fd()).collect();
+                    // Serve exactly one client: the in-sandbox validator connects
+                    // once. A second connection (e.g. the wake from `Drop`) is
+                    // distinguished by the `shutdown` flag and never served.
+                    match listener.accept() {
+                        Ok((stream, _)) => {
+                            if !shutdown.load(Ordering::SeqCst) {
+                                if let Err(error) = send_fds(&stream, &raw_fds) {
+                                    log::warn!("[sandbox] failed to send validation fds: {error}");
+                                }
+                            }
+                        }
+                        Err(error) => {
+                            if !shutdown.load(Ordering::SeqCst) {
+                                log::warn!("[sandbox] sandbox validation accept failed: {error}");
+                            }
+                        }
+                    }
+                    drop(fds);
+                    // Close the listener and remove the socket so it can't be
+                    // connected to again. The directory is removed by `Drop`.
+                    drop(listener);
+                    let _ = std::fs::remove_file(&host_socket_path);
+                }
+            })?;
+
+        Ok(Self {
+            host_socket_dir,
+            host_socket_path,
+            sandbox_socket_path,
+            shutdown,
+        })
+    }
+
+    pub(crate) fn host_socket_path(&self) -> &Path {
+        &self.host_socket_path
+    }
+
+    pub(crate) fn sandbox_socket_path(&self) -> &Path {
+        &self.sandbox_socket_path
+    }
+}
+
+impl Drop for ValidationFdSender {
+    fn drop(&mut self) {
+        // If the validator never connected (e.g. bwrap failed before the
+        // launcher ran), the thread is still blocked in `accept`. Flag shutdown
+        // and poke the socket so the accept returns and the thread exits without
+        // serving the descriptors, then remove the socket and its private
+        // directory (a no-op if the thread already removed the socket after its
+        // single send).
+        self.shutdown.store(true, Ordering::SeqCst);
+        let _ = UnixStream::connect(&self.host_socket_path);
+        let _ = std::fs::remove_file(&self.host_socket_path);
+        let _ = std::fs::remove_dir_all(&self.host_socket_dir);
+    }
+}
+
 fn push_bind(args: &mut Vec, flag: &str, source: &str, destination: &str) {
     args.push(flag.to_string());
     args.push(source.to_string());
@@ -323,38 +539,58 @@ fn resolve_bwrap() -> std::result::Result {
 }
 
 fn prepare_sandbox(
-    writable_dirs: &[&Path],
     permissions: SandboxPermissions,
-    cwd: Option<&Path>,
-    proxy_socket_path: Option<&Path>,
 ) -> std::result::Result<(PathBuf, Vec), LauncherStatus> {
-    let bwrap = resolve_bwrap()?;
-    // The probe only checks whether a sandbox can be created at all, so Git
-    // protection (which doesn't affect createability) is irrelevant here.
-    let bwrap_args = build_bwrap_args(writable_dirs, &[], permissions, cwd, proxy_socket_path);
+    let bwrap = match resolve_bwrap() {
+        Ok(bwrap) => bwrap,
+        Err(status) => {
+            log::warn!("[sandbox] cannot create sandbox: {}", status.describe());
+            return Err(status);
+        }
+    };
+    // The probe only answers "can a sandbox be created on this host at all", so
+    // it runs a bare, representative sandbox: no writable grants, no protected
+    // Git dirs, no proxy socket, and no `--chdir` into a command's working
+    // directory. None of those affect createability, and binding them would make
+    // the probe depend on per-command layout (e.g. a worktree under the `/tmp`
+    // tmpfs that `--chdir` then can't reach).
+    let bwrap_args = build_bwrap_args(&[], &[], permissions, None, None);
     if !probe_bwrap(&bwrap, &bwrap_args) {
         return Err(LauncherStatus::SandboxProbeFailed);
     }
     Ok((bwrap, bwrap_args))
 }
 
-/// Check whether an OS sandbox can be created for this policy.
+/// Check whether an OS sandbox can be created on this host for this policy.
 pub fn check_can_create_sandbox(
-    writable_dirs: &[&Path],
     permissions: SandboxPermissions,
-    cwd: Option<&Path>,
 ) -> std::result::Result<(), LauncherStatus> {
-    prepare_sandbox(writable_dirs, permissions, cwd, None).map(|_| ())
+    prepare_sandbox(permissions).map(|_| ())
+}
+
+/// The host (Zed-side) socket paths for the in-sandbox bind validator.
+#[derive(Clone, Copy)]
+pub struct ValidationSocket<'a> {
+    /// Host pathname of the listener the validator connects back to.
+    pub host_socket_path: &'a Path,
+    /// In-sandbox path the host socket is bind-mounted to (where the validator
+    /// actually connects).
+    pub sandbox_socket_path: &'a Path,
 }
 
 /// Build the final command line that runs `program` inside Bubblewrap.
 ///
-/// `bridge_program` should be the current Zed executable. It is only used for
-/// [`NetworkAccess::LocalhostPort`], where it runs in bridge mode inside the
-/// sandbox before spawning the real command.
+/// `bridge_program` should be the current Zed executable; it is re-exec'd inside
+/// the sandbox as the launcher whenever bind validation and/or the
+/// restricted-network bridge are needed, running before the real command.
 ///
-/// The host `proxy_socket_path` is bind-mounted to a per-invocation unique path
-/// inside the sandbox, and that same in-sandbox path is handed to the bridge.
+/// The host `proxy_socket_path` and `validation_socket` are each bind-mounted to
+/// a per-invocation path inside the sandbox, and those in-sandbox paths are
+/// handed to the launcher.
+#[allow(
+    clippy::too_many_arguments,
+    reason = "assembling a bwrap command line is inherently parameter-heavy"
+)]
 pub fn wrap_invocation(
     bridge_program: &str,
     permissions: SandboxPermissions,
@@ -364,11 +600,18 @@ pub fn wrap_invocation(
     program: &str,
     args: &[String],
     proxy_socket_path: Option<&Path>,
+    validation_socket: Option>,
 ) -> Result<(String, Vec)> {
     if matches!(permissions.network, NetworkAccess::LocalhostPort(_)) && proxy_socket_path.is_none()
     {
         bail!("restricted Linux network access requires a proxy Unix socket path");
     }
+    if writable_dirs.len() > MAX_VALIDATED_BINDS {
+        bail!(
+            "too many writable binds to validate ({} > {MAX_VALIDATED_BINDS})",
+            writable_dirs.len()
+        );
+    }
 
     // Create the requested writable directories up front, with the agent's
     // ambient permissions, so each can be bind-mounted at its exact path (see
@@ -392,28 +635,62 @@ pub fn wrap_invocation(
         NetworkAccess::LocalhostPort(_) => Some(unique_proxy_socket_sandbox_path()),
         NetworkAccess::None | NetworkAccess::All => None,
     };
-    let mut bwrap_args = build_bwrap_args_with_proxy_socket_sandbox_path(
+    let mut bwrap_args = build_bwrap_args_with_sandbox_paths(
         writable_dirs,
         protected_git_dirs,
         permissions,
         cwd,
         proxy_socket_path,
         proxy_socket_sandbox_path.as_deref(),
+        validation_socket.map(|socket| socket.host_socket_path),
+        validation_socket.map(|socket| socket.sandbox_socket_path),
     );
     bwrap_args.push("--".to_string());
 
-    match permissions.network {
+    let bridge = match permissions.network {
         NetworkAccess::LocalhostPort(port) => {
             let proxy_socket_sandbox_path = proxy_socket_sandbox_path
                 .as_ref()
                 .context("missing in-sandbox proxy socket path")?;
-            bwrap_args.push(bridge_program.to_string());
-            bwrap_args.push(BRIDGE_FLAG.to_string());
-            bwrap_args.push(proxy_socket_sandbox_path.to_string_lossy().into_owned());
-            bwrap_args.push(port.to_string());
-            bwrap_args.push("--".to_string());
+            Some((proxy_socket_sandbox_path.clone(), port))
+        }
+        NetworkAccess::None | NetworkAccess::All => None,
+    };
+
+    // The launcher is only needed when there is something for it to do: validate
+    // writable binds, and/or run the restricted-network bridge. Otherwise the
+    // command runs directly under bwrap.
+    if validation_socket.is_some() || bridge.is_some() {
+        bwrap_args.push(bridge_program.to_string());
+        bwrap_args.push(LAUNCHER_FLAG.to_string());
+        // Field 1: validation socket (in-sandbox path) or sentinel.
+        bwrap_args.push(match validation_socket {
+            Some(socket) => socket.sandbox_socket_path.to_string_lossy().into_owned(),
+            None => LAUNCHER_NONE.to_string(),
+        });
+        // Fields 2-3: bridge socket (in-sandbox path) + port, or sentinels.
+        match &bridge {
+            Some((socket, port)) => {
+                bwrap_args.push(socket.to_string_lossy().into_owned());
+                bwrap_args.push(port.to_string());
+            }
+            None => {
+                bwrap_args.push(LAUNCHER_NONE.to_string());
+                bwrap_args.push(LAUNCHER_NONE.to_string());
+            }
         }
-        NetworkAccess::None | NetworkAccess::All => {}
+        // Field 4: the writable bind-destination paths to validate (count, then
+        // the paths), in the same order the host sends their fds.
+        let validation_paths: &[&Path] = if validation_socket.is_some() {
+            writable_dirs
+        } else {
+            &[]
+        };
+        bwrap_args.push(validation_paths.len().to_string());
+        for path in validation_paths {
+            bwrap_args.push(path.to_string_lossy().into_owned());
+        }
+        bwrap_args.push("--".to_string());
     }
 
     bwrap_args.push(program.to_string());
@@ -425,66 +702,182 @@ pub fn wrap_invocation(
     Ok((bwrap.to_string(), bwrap_args))
 }
 
-/// Handle a possible re-exec of this binary as the in-sandbox proxy bridge.
+/// Handle a possible re-exec of this binary as the in-sandbox launcher (bind
+/// validator + network bridge). Does not return if it was invoked as one.
 pub fn run_launcher_if_invoked() {
-    let Some(invocation) = parse_bridge_args(std::env::args_os()) else {
+    let Some(invocation) = parse_launcher_args(std::env::args_os()) else {
         return;
     };
     let invocation = match invocation {
         Ok(invocation) => invocation,
         Err(error) => {
-            eprintln!("zed: malformed sandbox bridge invocation: {error:#}");
+            eprintln!("zed: malformed sandbox launcher invocation: {error:#}");
             std::process::exit(127);
         }
     };
-    run_bridge(invocation);
+    run_launcher(invocation);
 }
 
-struct BridgeInvocation {
-    socket_path: PathBuf,
-    port: u16,
+/// A decoded in-sandbox launcher invocation (the `--zed-linux-sandbox-launcher`
+/// re-exec). All fields are produced by the trusted host side and parsed before
+/// any untrusted command runs.
+struct LauncherInvocation {
+    /// In-sandbox path of the validation socket, if bind validation is required.
+    validation_socket: Option,
+    /// Writable bind-destination paths to validate, in the order the host sends
+    /// their fds. Empty when validation isn't required.
+    validation_paths: Vec,
+    /// `(in-sandbox proxy socket path, loopback port)` if the restricted-network
+    /// bridge is required.
+    bridge: Option<(PathBuf, u16)>,
     program: OsString,
     args: Vec,
 }
 
-fn parse_bridge_args(args: impl IntoIterator) -> Option> {
+fn parse_launcher_args(
+    args: impl IntoIterator,
+) -> Option> {
     let mut args = args.into_iter();
     args.next()?;
-    if args.next()?.to_str() != Some(BRIDGE_FLAG) {
+    if args.next()?.to_str() != Some(LAUNCHER_FLAG) {
         return None;
     }
-    Some(decode_bridge_args(args))
+    Some(decode_launcher_args(args))
+}
+
+/// Parse an optional field encoded as either a real value or the `-` sentinel.
+fn optional_field(value: OsString) -> Option {
+    if value == LAUNCHER_NONE {
+        None
+    } else {
+        Some(value)
+    }
 }
 
-fn decode_bridge_args(mut args: impl Iterator) -> Result {
-    let socket_path = PathBuf::from(args.next().context("missing proxy socket path")?);
-    let port = args
+fn decode_launcher_args(mut args: impl Iterator) -> Result {
+    let validation_socket =
+        optional_field(args.next().context("missing validation socket field")?).map(PathBuf::from);
+    let bridge_socket =
+        optional_field(args.next().context("missing bridge socket field")?).map(PathBuf::from);
+    let bridge_port = optional_field(args.next().context("missing bridge port field")?)
+        .map(|value| {
+            value
+                .to_str()
+                .context("bridge port is not valid UTF-8")?
+                .parse::()
+                .context("invalid bridge port")
+        })
+        .transpose()?;
+    let bridge = match (bridge_socket, bridge_port) {
+        (Some(socket), Some(port)) => Some((socket, port)),
+        (None, None) => None,
+        _ => bail!("bridge socket and port must be set together"),
+    };
+
+    let path_count = args
         .next()
-        .context("missing proxy bridge port")?
+        .context("missing validation path count")?
         .to_str()
-        .context("proxy bridge port is not valid UTF-8")?
-        .parse::()
-        .context("invalid proxy bridge port")?;
-    let separator = args.next().context("missing bridge argument separator")?;
+        .context("validation path count is not valid UTF-8")?
+        .parse::()
+        .context("invalid validation path count")?;
+    if path_count > MAX_VALIDATED_BINDS {
+        bail!("validation path count {path_count} exceeds {MAX_VALIDATED_BINDS}");
+    }
+    let mut validation_paths = Vec::with_capacity(path_count);
+    for _ in 0..path_count {
+        validation_paths.push(PathBuf::from(
+            args.next().context("missing validation path")?,
+        ));
+    }
+
+    let separator = args.next().context("missing launcher argument separator")?;
     if separator != "--" {
-        bail!("missing bridge argument separator");
+        bail!("missing launcher argument separator");
     }
     let program = args.next().context("missing program to run")?;
     let args = args.collect();
-    Ok(BridgeInvocation {
-        socket_path,
-        port,
+    Ok(LauncherInvocation {
+        validation_socket,
+        validation_paths,
+        bridge,
         program,
         args,
     })
 }
 
+/// The in-sandbox launcher entry point. Runs after bwrap's mounts and before the
+/// real command: it verifies the writable binds weren't redirected, optionally
+/// starts the restricted-network bridge, then runs the command. Never returns.
+fn run_launcher(invocation: LauncherInvocation) -> ! {
+    if let Some(socket) = &invocation.validation_socket {
+        if let Err(error) = validate_binds(socket, &invocation.validation_paths) {
+            // Fail closed: a redirected (or unverifiable) writable bind means the
+            // command must not run at all.
+            eprintln!("zed: sandbox bind validation failed: {error:#}");
+            std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
+        }
+    }
+
+    match invocation.bridge {
+        Some((socket_path, port)) => {
+            run_bridge(socket_path, port, &invocation.program, &invocation.args)
+        }
+        // No bridge to keep alive, so `exec` the command directly rather than
+        // lingering as a parent process.
+        None => exec_command(&invocation.program, &invocation.args),
+    }
+}
+
+/// Verify each writable bind resolves, inside the sandbox, to the exact inode the
+/// host captured. Receives the captured `O_PATH` fds over `socket_path` via
+/// `SCM_RIGHTS` (in the same order as `paths`), then compares `fstat(received
+/// fd)` against `lstat(mounted path)`. Both stats run in this process inside the
+/// sandbox, so the comparison needs no cross-namespace assumption about device
+/// numbers. Any mismatch — or any failure to obtain the expected number of fds —
+/// is an error (the caller fails closed).
+fn validate_binds(socket_path: &Path, paths: &[PathBuf]) -> Result<()> {
+    let stream = UnixStream::connect(socket_path)
+        .with_context(|| format!("connecting to validation socket {}", socket_path.display()))?;
+    let fds = recv_fds(&stream).context("receiving validation descriptors")?;
+    if fds.len() != paths.len() {
+        bail!(
+            "expected {} validation descriptor(s), received {}",
+            paths.len(),
+            fds.len()
+        );
+    }
+    for (fd, path) in fds.iter().zip(paths) {
+        let expected = fd_dev_ino(fd.as_raw_fd())
+            .with_context(|| format!("fstat of captured descriptor for {}", path.display()))?;
+        let mounted = lstat_dev_ino(path)
+            .with_context(|| format!("lstat of mounted bind {}", path.display()))?;
+        if expected != mounted {
+            bail!(
+                "writable bind {} was redirected (captured inode {:?}, mounted inode {:?})",
+                path.display(),
+                expected,
+                mounted
+            );
+        }
+    }
+    Ok(())
+}
+
+/// Replace this process with the sandboxed command. Only returns (after logging)
+/// if `exec` itself fails.
+fn exec_command(program: &OsStr, args: &[OsString]) -> ! {
+    let error = Command::new(program).args(args).exec();
+    eprintln!("zed: failed to exec sandboxed command: {error}");
+    std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
+}
+
 #[allow(
     clippy::disallowed_methods,
     reason = "the bridge is an in-sandbox process that must synchronously spawn and wait for the command"
 )]
-fn run_bridge(invocation: BridgeInvocation) -> ! {
-    let listener = match TcpListener::bind((Ipv4Addr::LOCALHOST, invocation.port)) {
+fn run_bridge(socket_path: PathBuf, port: u16, program: &OsStr, program_args: &[OsString]) -> ! {
+    let listener = match TcpListener::bind((Ipv4Addr::LOCALHOST, port)) {
         Ok(listener) => listener,
         Err(error) => {
             eprintln!("zed: failed to bind sandbox proxy bridge: {error}");
@@ -492,7 +885,6 @@ fn run_bridge(invocation: BridgeInvocation) -> ! {
         }
     };
 
-    let socket_path = invocation.socket_path.clone();
     if let Err(error) = thread::Builder::new()
         .name("zed-sandbox-bridge".to_string())
         .stack_size(128 * 1024)
@@ -502,10 +894,7 @@ fn run_bridge(invocation: BridgeInvocation) -> ! {
         std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
     }
 
-    let mut child = match Command::new(&invocation.program)
-        .args(&invocation.args)
-        .spawn()
-    {
+    let mut child = match Command::new(program).args(program_args).spawn() {
         Ok(child) => child,
         Err(error) => {
             eprintln!("zed: failed to spawn sandboxed command: {error}");
@@ -528,6 +917,281 @@ fn run_bridge(invocation: BridgeInvocation) -> ! {
     }
 }
 
+/// Send `fds` over `stream` in a single message carrying one byte of payload and
+/// the descriptors as `SCM_RIGHTS` ancillary data.
+fn send_fds(stream: &UnixStream, fds: &[RawFd]) -> std::io::Result<()> {
+    use nix::sys::socket::{ControlMessage, MsgFlags, sendmsg};
+    let payload = [0u8; 1];
+    let iov = [std::io::IoSlice::new(&payload)];
+    let control = [ControlMessage::ScmRights(fds)];
+    sendmsg::<()>(
+        stream.as_raw_fd(),
+        &iov,
+        &control,
+        MsgFlags::MSG_NOSIGNAL,
+        None,
+    )
+    .map_err(std::io::Error::from)?;
+    Ok(())
+}
+
+/// Receive the descriptors sent via `SCM_RIGHTS` on `stream`. They arrive with
+/// `O_CLOEXEC` (via `MSG_CMSG_CLOEXEC`) so they don't leak into the command
+/// that's `exec`'d after validation. The ancillary buffer is sized for the
+/// protocol maximum ([`MAX_VALIDATED_BINDS`]); a sender exceeding it trips
+/// `MSG_CTRUNC` and a mismatched count is rejected by the caller.
+fn recv_fds(stream: &UnixStream) -> std::io::Result> {
+    use nix::sys::socket::{ControlMessageOwned, MsgFlags, recvmsg};
+    let mut payload = [0u8; 1];
+    let mut iov = [std::io::IoSliceMut::new(&mut payload)];
+    let mut control = nix::cmsg_space!([RawFd; MAX_VALIDATED_BINDS]);
+    let message = recvmsg::<()>(
+        stream.as_raw_fd(),
+        &mut iov,
+        Some(&mut control),
+        MsgFlags::MSG_CMSG_CLOEXEC,
+    )
+    .map_err(std::io::Error::from)?;
+    if message.flags.contains(MsgFlags::MSG_CTRUNC) {
+        return Err(std::io::Error::other(
+            "validation descriptors were truncated in transit",
+        ));
+    }
+
+    let mut fds = Vec::new();
+    for control_message in message.cmsgs().map_err(std::io::Error::from)? {
+        if let ControlMessageOwned::ScmRights(raw_fds) = control_message {
+            for raw in raw_fds {
+                // SAFETY: the kernel installs each `SCM_RIGHTS` descriptor as a
+                // freshly-allocated, open fd in *our* table for this message
+                // (with `O_CLOEXEC` via `MSG_CMSG_CLOEXEC`), so it's valid and
+                // can't alias an fd we already hold. `nix`'s `ScmRights` does not
+                // take ownership of received fds (closing them is the caller's
+                // responsibility), so wrapping each exactly once here makes us
+                // their sole owner. Both hold for any peer, which is why this
+                // (and `recv_fds`) is sound as a safe fn rather than needing an
+                // `unsafe fn` precondition on the caller.
+                fds.push(unsafe { OwnedFd::from_raw_fd(raw) });
+            }
+        }
+    }
+    Ok(fds)
+}
+
+/// `(device, inode)` of the object an already-open descriptor refers to.
+fn fd_dev_ino(fd: RawFd) -> std::io::Result<(u64, u64)> {
+    let stat = nix::sys::stat::fstat(fd).map_err(std::io::Error::from)?;
+    Ok((stat.st_dev as u64, stat.st_ino as u64))
+}
+
+/// `(device, inode)` of `path` without following a final symlink.
+fn lstat_dev_ino(path: &Path) -> std::io::Result<(u64, u64)> {
+    // `symlink_metadata` is `lstat(2)` (it does not follow a final symlink).
+    let metadata = std::fs::symlink_metadata(path)?;
+    Ok((metadata.dev(), metadata.ino()))
+}
+
+/// Open an `O_PATH` descriptor pinning `path`'s inode (read/write on contents is
+/// not granted), the same capture the native-Linux policy layer performs in
+/// `HostFilesystemLocation::new`.
+fn open_o_path_fd(path: &Path) -> std::io::Result {
+    use std::os::unix::fs::OpenOptionsExt as _;
+    let file = std::fs::OpenOptions::new()
+        .read(true)
+        .custom_flags(libc::O_PATH | libc::O_CLOEXEC)
+        .open(path)?;
+    Ok(OwnedFd::from(file))
+}
+
+/// A decoded WSL-helper invocation (`--wsl-sandbox-helper`). All fields are
+/// produced by the trusted Windows side and parsed before any untrusted command
+/// runs.
+struct WslHelperInvocation {
+    /// Absolute path of `bwrap` inside WSL (located by the Windows-side probe).
+    bwrap_path: PathBuf,
+    /// Pre-built bwrap *options* — everything before the trailing `-- cmd` —
+    /// assembled on the Windows side (`windows_wsl::build_bwrap_args`): root
+    /// bind, `/tmp` tmpfs, writable binds, Windows-interop blocking, `--setenv`s,
+    /// `--chdir`, namespace flags, etc.
+    base_args: Vec,
+    /// The writable bind destinations to validate — exactly the ones `base_args`
+    /// binds read-write. Each is captured here (WSL-side) and checked in-sandbox.
+    writable_paths: Vec,
+    program: OsString,
+    args: Vec,
+}
+
+/// Handle a possible re-exec of this binary as the WSL-side sandbox helper. Does
+/// not return if it was invoked as one.
+pub fn run_wsl_helper_if_invoked() {
+    let Some(invocation) = parse_wsl_helper_args(std::env::args_os()) else {
+        return;
+    };
+    let invocation = match invocation {
+        Ok(invocation) => invocation,
+        Err(error) => {
+            eprintln!("zed: malformed WSL sandbox helper invocation: {error:#}");
+            std::process::exit(127);
+        }
+    };
+    run_wsl_helper(invocation);
+}
+
+fn parse_wsl_helper_args(
+    args: impl IntoIterator,
+) -> Option> {
+    let mut args = args.into_iter();
+    args.next()?;
+    if args.next()?.to_str() != Some(WSL_HELPER_FLAG) {
+        return None;
+    }
+    Some(decode_wsl_helper_args(args))
+}
+
+fn decode_wsl_helper_args(mut args: impl Iterator) -> Result {
+    let bwrap_path = PathBuf::from(args.next().context("missing bwrap path")?);
+    let base_count = parse_count(
+        args.next().context("missing base-arg count")?,
+        "base-arg count",
+    )?;
+    let mut base_args = Vec::with_capacity(base_count);
+    for _ in 0..base_count {
+        base_args.push(args.next().context("missing base arg")?);
+    }
+    let writable_count = parse_count(
+        args.next().context("missing writable count")?,
+        "writable count",
+    )?;
+    if writable_count > MAX_VALIDATED_BINDS {
+        bail!("writable count {writable_count} exceeds {MAX_VALIDATED_BINDS}");
+    }
+    let mut writable_paths = Vec::with_capacity(writable_count);
+    for _ in 0..writable_count {
+        writable_paths.push(PathBuf::from(args.next().context("missing writable path")?));
+    }
+    let separator = args.next().context("missing argument separator")?;
+    if separator != "--" {
+        bail!("missing argument separator");
+    }
+    let program = args.next().context("missing program to run")?;
+    let args = args.collect();
+    Ok(WslHelperInvocation {
+        bwrap_path,
+        base_args,
+        writable_paths,
+        program,
+        args,
+    })
+}
+
+fn parse_count(value: OsString, what: &str) -> Result {
+    value
+        .to_str()
+        .with_context(|| format!("{what} is not valid UTF-8"))?
+        .parse::()
+        .with_context(|| format!("invalid {what}"))
+}
+
+/// The WSL-side helper entry point. Mirrors the native-Linux host side: capture
+/// the writable binds' inodes (here, inside WSL), stand up the validation socket,
+/// finish assembling the bwrap invocation (validation socket bind + in-sandbox
+/// validator re-exec), spawn bwrap, and propagate its exit. Never returns.
+#[allow(
+    clippy::disallowed_methods,
+    reason = "the WSL helper is a dedicated per-command process that must spawn and wait for bwrap"
+)]
+fn run_wsl_helper(invocation: WslHelperInvocation) -> ! {
+    // Capture an `O_PATH` fd per writable bind *here*, inside WSL — this is the
+    // capture-at-validation step that on native Linux happens in the Zed process.
+    let mut fds = Vec::with_capacity(invocation.writable_paths.len());
+    for path in &invocation.writable_paths {
+        match open_o_path_fd(path) {
+            Ok(fd) => fds.push(fd),
+            Err(error) => {
+                // Fail closed: a writable bind we can't pin can't be verified.
+                eprintln!(
+                    "zed: WSL sandbox helper could not open writable bind {}: {error}",
+                    path.display()
+                );
+                std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
+            }
+        }
+    }
+
+    let validation = if fds.is_empty() {
+        None
+    } else {
+        match ValidationFdSender::spawn(fds) {
+            Ok(sender) => Some(sender),
+            Err(error) => {
+                eprintln!("zed: WSL sandbox helper could not start the bind validator: {error}");
+                std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
+            }
+        }
+    };
+
+    let current_exe = match std::env::current_exe() {
+        Ok(path) => path,
+        Err(error) => {
+            eprintln!("zed: WSL sandbox helper could not resolve its own path: {error}");
+            std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
+        }
+    };
+
+    let mut args = invocation.base_args.clone();
+    if let Some(sender) = &validation {
+        // Bind the validation socket in (after the base args' tmpfs and writable
+        // binds so it isn't shadowed), then re-exec ourselves inside the sandbox
+        // as the validator before the real command. WSL has no restricted-network
+        // bridge, so both bridge fields are the absent sentinel.
+        args.push(OsString::from("--bind"));
+        args.push(sender.host_socket_path().as_os_str().to_os_string());
+        args.push(sender.sandbox_socket_path().as_os_str().to_os_string());
+        args.push(OsString::from("--"));
+        args.push(current_exe.into_os_string());
+        args.push(OsString::from(LAUNCHER_FLAG));
+        args.push(sender.sandbox_socket_path().as_os_str().to_os_string());
+        args.push(OsString::from(LAUNCHER_NONE));
+        args.push(OsString::from(LAUNCHER_NONE));
+        args.push(OsString::from(invocation.writable_paths.len().to_string()));
+        for path in &invocation.writable_paths {
+            args.push(path.clone().into_os_string());
+        }
+        args.push(OsString::from("--"));
+    } else {
+        // Nothing to validate — run the command directly under bwrap.
+        args.push(OsString::from("--"));
+    }
+    args.push(invocation.program.clone());
+    args.extend(invocation.args.iter().cloned());
+
+    let mut child = match Command::new(&invocation.bwrap_path).args(&args).spawn() {
+        Ok(child) => child,
+        Err(error) => {
+            eprintln!("zed: WSL sandbox helper could not spawn bwrap: {error}");
+            std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
+        }
+    };
+
+    let status = child.wait();
+    // Hold the validator open until bwrap (and thus the in-sandbox validator that
+    // connects to it during startup) has finished.
+    drop(validation);
+    match status {
+        Ok(status) => {
+            if let Some(code) = status.code() {
+                std::process::exit(code);
+            }
+            let signal = status.signal().unwrap_or(1);
+            std::process::exit(128 + signal);
+        }
+        Err(error) => {
+            eprintln!("zed: WSL sandbox helper failed waiting for bwrap: {error}");
+            std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE);
+        }
+    }
+}
+
 fn run_bridge_listener(listener: TcpListener, socket_path: PathBuf) {
     for stream in listener.incoming() {
         match stream {
@@ -630,8 +1294,8 @@ fn copy_one_way(mut from: impl Read, mut to: impl BridgeStream) {
 mod tests {
     use super::*;
 
-    fn bridge_argv(launcher: &str, args: Vec<&str>) -> Vec {
-        std::iter::once(launcher)
+    fn launcher_argv(program: &str, args: Vec<&str>) -> Vec {
+        std::iter::once(program)
             .chain(args)
             .map(OsString::from)
             .collect()
@@ -678,8 +1342,9 @@ mod tests {
         );
 
         assert!(windows_contains(&args, &["--ro-bind", "/", "/"]));
-        let writable_path = writable.path().canonicalize().unwrap();
-        let writable_str = writable_path.to_string_lossy().into_owned();
+        // The writable dir is bound verbatim at the exact path given (never
+        // re-canonicalized, which would reopen the bind-source TOCTOU gap).
+        let writable_str = writable.path().to_string_lossy().into_owned();
         assert!(windows_contains(
             &args,
             &["--bind", &writable_str, &writable_str]
@@ -736,14 +1401,19 @@ mod tests {
     }
 
     #[test]
-    fn test_bridge_args_round_trip() {
-        let sandbox_socket_path = "/tmp/zed-sandbox-1234-0.sock";
-        let argv = bridge_argv(
+    fn test_launcher_args_round_trip_bridge_and_validation() {
+        let bridge_socket = "/tmp/zed-sandbox-1234-0.sock";
+        let validate_socket = "/tmp/zed-sandbox-validate-1234-0.sock";
+        let argv = launcher_argv(
             "/path/to/zed",
             vec![
-                BRIDGE_FLAG,
-                sandbox_socket_path,
+                LAUNCHER_FLAG,
+                validate_socket,
+                bridge_socket,
                 "8080",
+                "2",
+                "/work/a",
+                "/work/b",
                 "--",
                 "/bin/sh",
                 "-c",
@@ -751,12 +1421,22 @@ mod tests {
             ],
         );
 
-        let decoded = parse_bridge_args(argv)
-            .expect("should be recognized as bridge invocation")
+        let decoded = parse_launcher_args(argv)
+            .expect("should be recognized as launcher invocation")
             .expect("should decode successfully");
 
-        assert_eq!(decoded.socket_path, PathBuf::from(sandbox_socket_path));
-        assert_eq!(decoded.port, 8080);
+        assert_eq!(
+            decoded.validation_socket,
+            Some(PathBuf::from(validate_socket))
+        );
+        assert_eq!(
+            decoded.validation_paths,
+            vec![PathBuf::from("/work/a"), PathBuf::from("/work/b")]
+        );
+        assert_eq!(
+            decoded.bridge,
+            Some((PathBuf::from(bridge_socket), 8080u16))
+        );
         assert_eq!(decoded.program, OsString::from("/bin/sh"));
         assert_eq!(
             decoded.args,
@@ -764,6 +1444,80 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_wsl_helper_args_round_trip() {
+        let argv = launcher_argv(
+            "/path/to/zed",
+            vec![
+                WSL_HELPER_FLAG,
+                "/usr/bin/bwrap",
+                // base bwrap options (3 tokens)
+                "3",
+                "--ro-bind",
+                "/",
+                "/",
+                // writable binds to validate (1)
+                "1",
+                "/work/a",
+                "--",
+                "/bin/sh",
+                "-c",
+                "echo hi",
+            ],
+        );
+
+        let decoded = parse_wsl_helper_args(argv)
+            .expect("should be recognized as a WSL helper invocation")
+            .expect("should decode successfully");
+
+        assert_eq!(decoded.bwrap_path, PathBuf::from("/usr/bin/bwrap"));
+        assert_eq!(
+            decoded.base_args,
+            vec![
+                OsString::from("--ro-bind"),
+                OsString::from("/"),
+                OsString::from("/")
+            ]
+        );
+        assert_eq!(decoded.writable_paths, vec![PathBuf::from("/work/a")]);
+        assert_eq!(decoded.program, OsString::from("/bin/sh"));
+        assert_eq!(
+            decoded.args,
+            vec![OsString::from("-c"), OsString::from("echo hi")]
+        );
+    }
+
+    #[test]
+    fn test_launcher_args_round_trip_no_bridge() {
+        let validate_socket = "/tmp/zed-sandbox-validate-1234-0.sock";
+        let argv = launcher_argv(
+            "/path/to/zed",
+            vec![
+                LAUNCHER_FLAG,
+                validate_socket,
+                LAUNCHER_NONE,
+                LAUNCHER_NONE,
+                "1",
+                "/work/a",
+                "--",
+                "/bin/true",
+            ],
+        );
+
+        let decoded = parse_launcher_args(argv)
+            .expect("should be recognized as launcher invocation")
+            .expect("should decode successfully");
+
+        assert_eq!(
+            decoded.validation_socket,
+            Some(PathBuf::from(validate_socket))
+        );
+        assert_eq!(decoded.validation_paths, vec![PathBuf::from("/work/a")]);
+        assert_eq!(decoded.bridge, None);
+        assert_eq!(decoded.program, OsString::from("/bin/true"));
+        assert!(decoded.args.is_empty());
+    }
+
     #[test]
     fn test_wrap_invocation_uses_bridge_for_restricted_network() {
         let socket = PathBuf::from("/tmp/zed-proxy.sock");
@@ -780,7 +1534,8 @@ mod tests {
         );
 
         // The bind destination inside the sandbox and the path handed to the
-        // bridge must be the same unique path.
+        // launcher's bridge fields must be the same unique path. With no writable
+        // binds, the validation field is the `-` sentinel and the path count is 0.
         let sandbox_destination = proxy_socket_bind_destination(&args)
             .expect("restricted run should bind the proxy socket into the sandbox");
         assert!(sandbox_destination.starts_with(PROXY_SOCKET_SANDBOX_PATH_PREFIX));
@@ -788,14 +1543,19 @@ mod tests {
             &args,
             &[
                 "/path/to/zed",
-                BRIDGE_FLAG,
+                LAUNCHER_FLAG,
+                LAUNCHER_NONE,
                 &sandbox_destination,
                 "8080",
-                "--"
+                "0",
+                "--",
             ]
         ));
     }
 
+    /// Reconstruct the argv `wrap_invocation` would produce for the bridge-only
+    /// (no writable binds, no validation socket) case, without needing a real
+    /// `bwrap` on the test host.
     fn build_wrapped_args_for_test(
         bridge_program: &str,
         permissions: SandboxPermissions,
@@ -807,22 +1567,26 @@ mod tests {
             NetworkAccess::LocalhostPort(_) => Some(unique_proxy_socket_sandbox_path()),
             NetworkAccess::None | NetworkAccess::All => None,
         };
-        let mut bwrap_args = build_bwrap_args_with_proxy_socket_sandbox_path(
+        let mut bwrap_args = build_bwrap_args_with_sandbox_paths(
             &[],
             &[],
             permissions,
             None,
             proxy_socket_path,
             proxy_socket_sandbox_path.as_deref(),
+            None,
+            None,
         );
         bwrap_args.push("--".to_string());
         if let NetworkAccess::LocalhostPort(port) = permissions.network {
             let proxy_socket_sandbox_path =
                 proxy_socket_sandbox_path.expect("restricted network needs a sandbox socket path");
             bwrap_args.push(bridge_program.to_string());
-            bwrap_args.push(BRIDGE_FLAG.to_string());
+            bwrap_args.push(LAUNCHER_FLAG.to_string());
+            bwrap_args.push(LAUNCHER_NONE.to_string());
             bwrap_args.push(proxy_socket_sandbox_path.to_string_lossy().into_owned());
             bwrap_args.push(port.to_string());
+            bwrap_args.push("0".to_string());
             bwrap_args.push("--".to_string());
         }
         bwrap_args.push(program.to_string());
@@ -846,4 +1610,61 @@ mod tests {
             .windows(needle.len())
             .any(|window| window.iter().map(String::as_str).eq(needle.iter().copied()))
     }
+
+    /// Open an `O_PATH` descriptor to `path`, mirroring how the policy layer
+    /// captures a `HostFilesystemLocation`.
+    fn open_o_path(path: &Path) -> OwnedFd {
+        use std::os::unix::fs::OpenOptionsExt as _;
+        let file = std::fs::OpenOptions::new()
+            .read(true)
+            .custom_flags(libc::O_PATH | libc::O_CLOEXEC)
+            .open(path)
+            .expect("open O_PATH");
+        OwnedFd::from(file)
+    }
+
+    // End-to-end check of the bind validator's core, without a real sandbox:
+    // the server hands over the captured fd via SCM_RIGHTS and `validate_binds`
+    // compares it against the path it's told was mounted. A matching path passes;
+    // a *different* directory (as a redirected bind would produce) is rejected,
+    // proving the validator fails closed rather than no-ops.
+    //
+    // Each `ValidationFdSender` serves a single client and then tears down, so
+    // the two cases use separate senders.
+    #[test]
+    fn test_validate_binds_accepts_match_and_rejects_redirect() {
+        let captured = tempfile::tempdir().unwrap();
+        let other = tempfile::tempdir().unwrap();
+
+        // The mounted path is the captured inode -> validation passes.
+        let sender = ValidationFdSender::spawn(vec![open_o_path(captured.path())])
+            .expect("spawn validation fd sender");
+        validate_binds(sender.host_socket_path(), &[captured.path().to_path_buf()])
+            .expect("matching bind must validate");
+        drop(sender);
+
+        // The mounted path is a *different* inode (a redirected bind) -> rejected.
+        let sender = ValidationFdSender::spawn(vec![open_o_path(captured.path())])
+            .expect("spawn validation fd sender");
+        let error = validate_binds(sender.host_socket_path(), &[other.path().to_path_buf()])
+            .expect_err("a redirected bind must be rejected");
+        assert!(
+            error.to_string().contains("redirected"),
+            "unexpected error: {error:#}"
+        );
+    }
+
+    // A wrong fd count from the server (here: zero fds for one path) must fail
+    // closed too — the validator never assumes an unvalidated bind is fine.
+    #[test]
+    fn test_validate_binds_rejects_missing_descriptors() {
+        let captured = tempfile::tempdir().unwrap();
+        let sender = ValidationFdSender::spawn(Vec::new()).expect("spawn validation fd sender");
+        let error = validate_binds(sender.host_socket_path(), &[captured.path().to_path_buf()])
+            .expect_err("missing descriptors must be rejected");
+        assert!(
+            error.to_string().contains("descriptor"),
+            "unexpected error: {error:#}"
+        );
+    }
 }
diff --git a/crates/sandbox/src/macos_seatbelt.rs b/crates/sandbox/src/macos_seatbelt.rs
index ec4605f19810d2..91efe4467fe995 100644
--- a/crates/sandbox/src/macos_seatbelt.rs
+++ b/crates/sandbox/src/macos_seatbelt.rs
@@ -209,21 +209,19 @@ fn generate_seatbelt_config(
     allowed_unix_socket_paths: &[&Path],
     permissions: SandboxPermissions,
 ) -> Result {
-    // Canonicalize each writable path to resolve symlinks (e.g.,
-    // /var -> /private/var on macOS). Fall back to the original path if
-    // canonicalization fails.
+    // These paths are already the canonical identities captured once, at
+    // validation time, inside each `HostFilesystemLocation` (resolving symlinks
+    // and, for a not-yet-created `.git`, its existing parent). We deliberately do
+    // NOT re-canonicalize here: re-resolving a path at profile-generation time
+    // is the time-of-check-to-time-of-use hole this design closes. Use them
+    // verbatim as Seatbelt rule literals.
     let canonical_writable_directories: Vec = writable_directories
         .iter()
-        .map(|path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf()))
+        .map(|path| path.to_path_buf())
         .collect();
-    // Use `canonicalize_allowing_missing_leaf` rather than a plain
-    // `canonicalize` so a not-yet-created `.git` (before `git init`) still
-    // resolves through its existing parent and matches the canonicalized
-    // writable worktree above; otherwise the deny rule would miss the real path
-    // on a symlinked root (`/tmp` -> `/private/tmp`).
     let canonical_protected_paths: Vec = protected_paths
         .iter()
-        .map(|path| crate::canonicalize_allowing_missing_leaf(path))
+        .map(|path| path.to_path_buf())
         .collect();
     // Unlike file paths, Unix socket literals are emitted verbatim: it isn't
     // guaranteed whether Seatbelt resolves symlinks before matching a
@@ -564,7 +562,11 @@ mod tests {
         use std::process::Command;
 
         let temp_dir = tempfile::tempdir().unwrap();
-        let protected_file = temp_dir.path().join(".gitignore");
+        // Canonicalize so the policy paths resolve the macOS `/var` -> `/private/var`
+        // symlink; Seatbelt matches rules against the resolved path, as production
+        // does via `HostFilesystemLocation`.
+        let dir = std::fs::canonicalize(temp_dir.path()).unwrap();
+        let protected_file = dir.join(".gitignore");
         std::fs::write(&protected_file, "target\n").unwrap();
 
         let (program, args, _config_file) = wrap_invocation(
@@ -578,7 +580,7 @@ mod tests {
                     protected_file.display(),
                 ),
             ],
-            &[temp_dir.path()],
+            &[dir.as_path()],
             &[protected_file.as_path()],
             &[],
             SandboxPermissions::default(),
@@ -607,7 +609,10 @@ mod tests {
         use std::process::Command;
 
         let temp_dir = tempfile::tempdir().unwrap();
-        let protected_file = temp_dir.path().join(".gitignore");
+        // See the sibling protected-path test: canonicalize so policy paths resolve
+        // the macOS `/var` -> `/private/var` symlink that Seatbelt matches against.
+        let dir = std::fs::canonicalize(temp_dir.path()).unwrap();
+        let protected_file = dir.join(".gitignore");
         std::fs::write(&protected_file, "target\n").unwrap();
 
         let (program, args, _config_file) = wrap_invocation(
@@ -620,7 +625,7 @@ mod tests {
                     protected_file.display(),
                 ),
             ],
-            &[temp_dir.path()],
+            &[dir.as_path()],
             &[protected_file.as_path()],
             &[],
             SandboxPermissions {
@@ -870,7 +875,11 @@ mod tests {
 
         let project_dir = tempfile::tempdir().unwrap();
         let scratch_dir = tempfile::tempdir().unwrap();
-        let test_file = scratch_dir.path().join("test_write.txt");
+        // Canonicalize so the writable subpaths resolve the macOS `/var` ->
+        // `/private/var` symlink that Seatbelt matches against.
+        let project_path = std::fs::canonicalize(project_dir.path()).unwrap();
+        let scratch_path = std::fs::canonicalize(scratch_dir.path()).unwrap();
+        let test_file = scratch_path.join("test_write.txt");
 
         let (program, args, _config_file) = wrap_invocation(
             "/bin/sh",
@@ -878,7 +887,7 @@ mod tests {
                 "-c".to_string(),
                 format!("echo 'hello' > '{}'", test_file.display()),
             ],
-            &[project_dir.path(), scratch_dir.path()],
+            &[project_path.as_path(), scratch_path.as_path()],
             &[],
             &[],
             SandboxPermissions::default(),
@@ -903,7 +912,10 @@ mod tests {
         use std::process::Command;
 
         let temp_dir = tempfile::tempdir().unwrap();
-        let test_file = temp_dir.path().join("test_write.txt");
+        // Canonicalize so the writable subpath resolves the macOS `/var` ->
+        // `/private/var` symlink that Seatbelt matches against.
+        let dir = std::fs::canonicalize(temp_dir.path()).unwrap();
+        let test_file = dir.join("test_write.txt");
 
         let (program, args, _config_file) = wrap_invocation(
             "/bin/sh",
@@ -911,7 +923,7 @@ mod tests {
                 "-c".to_string(),
                 format!("echo 'hello' > '{}'", test_file.display()),
             ],
-            &[temp_dir.path()],
+            &[dir.as_path()],
             &[],
             &[],
             SandboxPermissions::default(),
diff --git a/crates/sandbox/src/sandbox.rs b/crates/sandbox/src/sandbox.rs
index 390fd4ed55296b..4873c46ed514b9 100644
--- a/crates/sandbox/src/sandbox.rs
+++ b/crates/sandbox/src/sandbox.rs
@@ -29,6 +29,249 @@ mod windows_wsl;
 #[cfg(target_os = "windows")]
 pub(crate) const WSL_SANDBOX_UNAVAILABLE_PREFIX: &str = "Windows sandboxing via WSL is unavailable";
 
+/// An opaque handle to a location on the **host** filesystem the sandbox may
+/// grant access to (a writable subtree, a `.git` directory, …).
+///
+/// The entire purpose of this type is to capture the *security-relevant identity*
+/// of a host location once, up front, in a form the enforcement layer can use
+/// without re-resolving a path string later. Re-resolving a path at enforcement
+/// time is the classic time-of-check-to-time-of-use hole: a path that was
+/// verified as safe can be swapped for a symlink before the sandbox actually
+/// binds/allows it, redirecting the grant to an arbitrary host location.
+///
+/// What is captured is platform-specific:
+/// - **macOS**: the fully-canonicalized path, used verbatim as the Seatbelt rule
+///   literal. Seatbelt matches the *resolved* access path against this literal,
+///   so a post-capture swap of a path component fails closed (denied) rather
+///   than redirecting the grant.
+/// - **Linux**: an `O_PATH` file descriptor pinned to the target inode. bwrap is
+///   launched by a PTY that can't inherit extra fds, so we can't use bwrap's own
+///   `--bind-fd`; instead the bind uses an ordinary `--bind ` and an
+///   in-sandbox validator compares `fstat` of this descriptor against `lstat` of
+///   the mounted path after the mounts, failing closed on a post-capture swap
+///   (see `linux_bubblewrap::validate_binds` and `README.md`).
+/// - **Windows**: nothing — a Windows process holds no Linux fds, so the real
+///   capture-at-validation happens inside WSL (in the `--wsl-sandbox-helper`),
+///   and the value here carries only the requested path as untrusted intent.
+///
+/// The type is deliberately **opaque**: it does not `Deref`, and it never hands
+/// back its trusted value. The only thing readable is a *display-only* path via
+/// [`HostFilesystemLocation::untrusted_path_display`], suitable for showing a
+/// human but which must never be passed back into a sandbox API as the
+/// location's identity. Equality reflects the actual filesystem object (same
+/// inode), not the textual path.
+#[derive(Clone)]
+pub struct HostFilesystemLocation {
+    /// macOS: the canonicalized path, resolved exactly once at capture time and
+    /// used directly as the Seatbelt rule literal.
+    #[cfg(target_os = "macos")]
+    canonical_path: PathBuf,
+    /// Linux: an `O_PATH` descriptor pinned to the captured inode. Wrapped in an
+    /// `Arc` only so the surrounding policy types can stay `Clone`; cloning
+    /// shares the same underlying descriptor.
+    #[cfg(target_os = "linux")]
+    fd: std::sync::Arc,
+    /// The path exactly as the caller requested it. Kept **only** so the UI can
+    /// show the user which location is being granted. This is never consulted by
+    /// any enforcement path — treat it as untrusted, attacker-influenced text.
+    untrusted_path_for_display: PathBuf,
+}
+
+impl HostFilesystemLocation {
+    /// Capture `path` as a host sandbox location, resolving its identity up front.
+    ///
+    /// On macOS this canonicalizes the path; on Linux it opens an `O_PATH`
+    /// descriptor to it; on Windows it records nothing. The caller is
+    /// responsible for having already *validated* `path` (e.g. confirmed it is
+    /// inside the project, or that a `.git` is not a symlink) — capturing it here
+    /// pins that decision against later tampering. To be race-free, capture
+    /// should happen as part of, or immediately after, that validation, and the
+    /// resulting value should be passed around unchanged from then on (never
+    /// re-derived from a path).
+    pub fn new(path: impl AsRef) -> std::io::Result {
+        let path = path.as_ref();
+        let untrusted_path_for_display = path.to_path_buf();
+
+        #[cfg(target_os = "macos")]
+        {
+            // `canonicalize_allowing_missing_leaf` resolves through the existing
+            // parent so a not-yet-created leaf (e.g. a `.git` before `git init`)
+            // still yields the real path Seatbelt will match against.
+            let canonical_path = canonicalize_allowing_missing_leaf(path);
+            Ok(Self {
+                canonical_path,
+                untrusted_path_for_display,
+            })
+        }
+        #[cfg(target_os = "linux")]
+        {
+            use std::os::unix::fs::OpenOptionsExt as _;
+            // `O_PATH` opens a handle that refers to the inode without granting
+            // read/write on its contents, which is exactly what a bind source
+            // needs. `O_CLOEXEC` keeps the descriptor from leaking into
+            // unrelated children; the bind step re-publishes it deliberately
+            // when launching bwrap.
+            let file = std::fs::OpenOptions::new()
+                .read(true)
+                .custom_flags(libc::O_PATH | libc::O_CLOEXEC)
+                .open(path)?;
+            Ok(Self {
+                fd: std::sync::Arc::new(std::os::fd::OwnedFd::from(file)),
+                untrusted_path_for_display,
+            })
+        }
+        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
+        {
+            Ok(Self {
+                untrusted_path_for_display,
+            })
+        }
+    }
+
+    /// The requested path, for **display only** (e.g. the permission-request UI).
+    ///
+    /// This intentionally returns the untrusted, as-requested path — never the
+    /// captured trusted identity. Do not feed the result back into any sandbox
+    /// API as if it identified this location.
+    pub fn untrusted_path_display(&self) -> std::path::Display<'_> {
+        self.untrusted_path_for_display.display()
+    }
+
+    /// macOS: the canonical path captured once at construction, used verbatim as
+    /// the Seatbelt rule literal. Trusted — never re-resolved. Falls back to the
+    /// requested path for a display-only location (which must never reach
+    /// enforcement).
+    #[cfg(target_os = "macos")]
+    pub(crate) fn macos_canonical_path(&self) -> &Path {
+        &self.canonical_path
+    }
+
+    /// Linux: a borrowed handle to the pinned inode, for deriving a bind source
+    /// path and for `fstat`-based identity checks.
+    #[cfg(target_os = "linux")]
+    pub(crate) fn linux_fd(&self) -> std::os::fd::BorrowedFd<'_> {
+        use std::os::fd::AsFd as _;
+        self.fd.as_fd()
+    }
+
+    /// Linux: an independent `O_PATH` descriptor to the same pinned inode,
+    /// duplicated (with `O_CLOEXEC`) so the validation server can own and send it
+    /// over `SCM_RIGHTS` without affecting this location's descriptor.
+    #[cfg(target_os = "linux")]
+    pub(crate) fn linux_dup_fd(&self) -> std::io::Result {
+        use std::os::fd::AsFd as _;
+        self.fd.as_fd().try_clone_to_owned()
+    }
+
+    /// Windows: the requested path, to be mapped into WSL and handed to the
+    /// in-WSL helper. Windows captures no identity itself (it holds no Linux
+    /// fds); the real capture-at-validation happens WSL-side in the helper, so
+    /// here the requested path *is* the location.
+    #[cfg(target_os = "windows")]
+    pub(crate) fn windows_path(&self) -> &Path {
+        &self.untrusted_path_for_display
+    }
+}
+
+impl fmt::Debug for HostFilesystemLocation {
+    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+        // Only the display path is shown; the trusted identity stays opaque.
+        formatter
+            .debug_struct("HostFilesystemLocation")
+            .field(
+                "untrusted_path_for_display",
+                &self.untrusted_path_for_display,
+            )
+            .finish_non_exhaustive()
+    }
+}
+
+impl PartialEq for HostFilesystemLocation {
+    /// Two locations are equal when they refer to the **same filesystem object**,
+    /// determined from the captured identity (the inode behind the `O_PATH` fd on
+    /// Linux, the canonical path on macOS) — never from the textual
+    /// display path. This is what lets policy bookkeeping dedupe "the same
+    /// location named two different ways," and refuse to treat "two different
+    /// objects that happen to share a path string" as one.
+    fn eq(&self, other: &Self) -> bool {
+        #[cfg(target_os = "linux")]
+        {
+            match (linux_fd_identity(&self.fd), linux_fd_identity(&other.fd)) {
+                (Some(a), Some(b)) => a == b,
+                // An `fstat` on an `O_PATH` fd we own should never fail; if it
+                // somehow does we can't prove identity, so report "not equal"
+                // (the safe answer) and leave a trace.
+                _ => {
+                    log::error!(
+                        "failed to fstat an O_PATH descriptor while comparing sandbox locations"
+                    );
+                    false
+                }
+            }
+        }
+        #[cfg(target_os = "macos")]
+        {
+            // Canonicalization is a bijection on real paths, so equal canonical
+            // paths mean the same directory/file.
+            self.canonical_path == other.canonical_path
+        }
+        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
+        {
+            // No enforcement and no captured identity on these platforms; fall
+            // back to the requested path purely so the type can still be used in
+            // collections.
+            self.untrusted_path_for_display == other.untrusted_path_for_display
+        }
+    }
+}
+
+impl Eq for HostFilesystemLocation {}
+
+/// The `(device, inode)` pair behind an `O_PATH` descriptor, used to decide
+/// whether two [`HostFilesystemLocation`]s refer to the same filesystem object.
+#[cfg(target_os = "linux")]
+fn linux_fd_identity(fd: &std::os::fd::OwnedFd) -> Option<(u64, u64)> {
+    use std::os::fd::AsRawFd as _;
+    let stat = nix::sys::stat::fstat(fd.as_raw_fd()).ok()?;
+    Some((stat.st_dev as u64, stat.st_ino as u64))
+}
+
+/// A path *inside the sandbox* — i.e. where a host location is exposed in the
+/// sandboxed process's view of the filesystem (for example, a bind-mount
+/// destination on Linux).
+///
+/// Unlike [`HostFilesystemLocation`], this needs no hardening and is just a thin
+/// wrapper around a [`PathBuf`]. It only names a location within the sandbox's
+/// own namespace: the worst a tampered sandbox-side path can do is expose the
+/// (already-granted) host files at a *different* path inside the sandbox — it can
+/// never widen which host files are reachable. It is therefore fine to build one
+/// from an ordinary, even attacker-influenced, path.
+#[derive(Clone, Debug, Eq, PartialEq, Hash)]
+pub struct SandboxFilesystemLocation(PathBuf);
+
+impl SandboxFilesystemLocation {
+    /// Name a location inside the sandbox's filesystem view.
+    pub fn new(path: impl Into) -> Self {
+        Self(path.into())
+    }
+
+    /// The in-sandbox path. Safe to read: this is not a trusted host identity.
+    pub fn as_path(&self) -> &Path {
+        &self.0
+    }
+
+    /// Consume this wrapper, yielding the underlying in-sandbox path.
+    pub fn into_path_buf(self) -> PathBuf {
+        self.0
+    }
+}
+
+impl From for SandboxFilesystemLocation {
+    fn from(path: PathBuf) -> Self {
+        Self(path)
+    }
+}
+
 /// What a command is allowed to do, expressed as intent. This is the entire
 /// public configuration surface; how each policy is enforced (Seatbelt rules,
 /// Bubblewrap flags, a loopback proxy, …) is an implementation detail.
@@ -44,9 +287,13 @@ pub struct SandboxPolicy {
 pub enum SandboxFsPolicy {
     /// Allow unrestricted filesystem writes.
     Unrestricted,
-    /// Reads are allowed everywhere; writes are confined to these directory
-    /// subtrees (and the standard ephemeral locations the platform provides).
-    Restricted { writable_paths: Vec },
+    /// Reads are allowed everywhere; writes are confined to these locations
+    /// (and the standard ephemeral locations the platform provides). Each is a
+    /// [`HostFilesystemLocation`] captured at validation time, never a bare path
+    /// the enforcement layer would re-resolve.
+    Restricted {
+        writable_paths: Vec,
+    },
 }
 
 /// Outbound-network policy for a sandboxed command.
@@ -72,9 +319,13 @@ pub enum GitSandboxPolicy {
     /// `.git` contents are protected: read-only on Linux and Windows/WSL;
     /// content reads and writes denied on macOS (metadata stays visible either
     /// way).
-    Denied { git_dirs: Vec },
+    Denied {
+        git_dirs: Vec,
+    },
     /// `.git` contents are writable (these dirs are made writable).
-    Allowed { git_dirs: Vec },
+    Allowed {
+        git_dirs: Vec,
+    },
 }
 
 impl Default for GitSandboxPolicy {
@@ -88,7 +339,7 @@ impl Default for GitSandboxPolicy {
 
 impl GitSandboxPolicy {
     /// The `.git` directories this policy governs, regardless of variant.
-    pub fn git_dirs(&self) -> &[PathBuf] {
+    pub fn git_dirs(&self) -> &[HostFilesystemLocation] {
         match self {
             GitSandboxPolicy::Denied { git_dirs } | GitSandboxPolicy::Allowed { git_dirs } => {
                 git_dirs
@@ -285,7 +536,7 @@ impl std::error::Error for SandboxError {}
 /// Resolved filesystem setup derived from [`SandboxFsPolicy`].
 struct FsSetup {
     allow_fs_write: bool,
-    writable_paths: Vec,
+    writable_paths: Vec,
 }
 
 /// Resolved network plan derived from [`SandboxNetPolicy`]. For the restricted
@@ -317,6 +568,23 @@ pub struct Sandbox {
     /// In-process network proxy for the restricted-network case, spawned on the
     /// first `wrap`. Dropped on a background thread (the join blocks).
     proxy: Option,
+    /// Linux only: the host endpoint that hands the in-sandbox validator the
+    /// captured `O_PATH` fds over a unix socket. Runs entirely in-process (a
+    /// short-lived background thread, never a separate process) and is owned by
+    /// this `Sandbox` — which is created per command — so it comes up when the
+    /// command is wrapped and is torn down (thread stopped, socket removed) when
+    /// the command finishes. Holds the fds, keeping their inodes pinned until
+    /// then. Created lazily on the wrap that first needs it (a restricted-fs run
+    /// with writable binds); a `Sandbox` normally wraps a single command.
+    #[cfg(target_os = "linux")]
+    validation_fd_sender: Option,
+    /// Windows only: `(release channel, version)` of the Linux `zed` to
+    /// provision inside WSL as the `--wsl-sandbox-helper` (version `latest` for
+    /// dev builds). Set by the caller (which has the running release info);
+    /// `None` falls back to exec'ing bwrap directly without in-sandbox bind
+    /// validation.
+    #[cfg(target_os = "windows")]
+    wsl_zed_release: Option<(String, String)>,
     #[cfg(target_os = "macos")]
     seatbelt_config: Option,
 }
@@ -353,29 +621,48 @@ impl Sandbox {
             network,
             git: policy.git,
             proxy: None,
+            #[cfg(target_os = "linux")]
+            validation_fd_sender: None,
+            #[cfg(target_os = "windows")]
+            wsl_zed_release: None,
             #[cfg(target_os = "macos")]
             seatbelt_config: None,
         })
     }
 
-    /// Check whether the platform sandbox can be created for `policy` without
+    /// Windows only: record the `(release channel, version)` of the Linux `zed`
+    /// to provision inside WSL as the sandbox helper (version `latest` for dev
+    /// builds). The caller resolves these from the running app's release info
+    /// (which this low-level crate can't read) and sets them before `wrap`. When
+    /// unset, the WSL backend falls back to exec'ing bwrap directly without
+    /// in-sandbox bind validation.
+    #[cfg(target_os = "windows")]
+    pub fn set_wsl_zed_release(&mut self, channel: String, version: String) {
+        self.wsl_zed_release = Some((channel, version));
+    }
+
+    /// Check whether the platform sandbox can be created on this host without
     /// actually building a command or spawning the proxy. On Linux this runs a
     /// brief `bwrap` probe (call it off the main thread).
-    pub fn can_create(policy: &SandboxPolicy, cwd: Option<&Path>) -> Result<(), SandboxError> {
+    ///
+    /// This answers a purely *environmental* question — is a bwrap sandbox
+    /// possible here at all (a usable `bwrap`, plus the unprivileged user
+    /// namespace and mount scaffolding we rely on)? It deliberately does **not**
+    /// depend on any command's writable grants or working directory: the probe
+    /// runs a bare, representative sandbox and runs `true` in it. (Unlike the
+    /// former Landlock probe, bwrap needs no ABI-matching against the real
+    /// ruleset, so there's no reason to mirror the real command's mounts.)
+    pub fn can_create(policy: &SandboxPolicy) -> Result<(), SandboxError> {
         #[cfg(target_os = "linux")]
         {
-            let writable = policy_writable_paths(policy);
-            let writable: Vec<&Path> = writable.iter().map(PathBuf::as_path).collect();
             let permissions = linux_bubblewrap::SandboxPermissions {
                 network: linux_probe_network(&policy.network),
                 allow_fs_write: matches!(policy.fs, SandboxFsPolicy::Unrestricted),
             };
-            linux_bubblewrap::check_can_create_sandbox(&writable, permissions, cwd)
-                .map_err(map_linux_status)
+            linux_bubblewrap::check_can_create_sandbox(permissions).map_err(map_linux_status)
         }
         #[cfg(target_os = "windows")]
         {
-            let _ = cwd;
             if matches!(policy.network, SandboxNetPolicy::Restricted { .. }) {
                 return Err(unsupported_restricted_network_on_windows());
             }
@@ -383,7 +670,7 @@ impl Sandbox {
         }
         #[cfg(not(any(target_os = "linux", target_os = "windows")))]
         {
-            let _ = (policy, cwd);
+            let _ = policy;
             Ok(())
         }
     }
@@ -491,7 +778,7 @@ impl Sandbox {
     /// is empty (Git protection is moot); otherwise `Allowed` git dirs are
     /// writable and `Denied` git dirs are protected.
     #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
-    fn git_path_split(&self) -> (Vec, Vec) {
+    fn git_path_split(&self) -> (Vec, Vec) {
         if self.fs.allow_fs_write {
             return (Vec::new(), Vec::new());
         }
@@ -520,14 +807,61 @@ impl Sandbox {
             allow_fs_write: self.fs.allow_fs_write,
         };
         let (git_writable, git_protected) = self.git_path_split();
-        let mut writable: Vec<&Path> = self
-            .fs
-            .writable_paths
+        // Build the writable binds as (captured fd, bind path) pairs in lockstep.
+        // The bind *path* is derived from the pinned inode (readlink of the
+        // captured `O_PATH` fd), never from an attacker-influenceable string; the
+        // *fd* is what the in-sandbox validator compares the mounted inode
+        // against. The two lists stay in the same order so each fd lines up with
+        // its path on the validator side.
+        let mut writable_owned: Vec = Vec::new();
+        let mut writable_fds: Vec = Vec::new();
+        for location in self.fs.writable_paths.iter().chain(git_writable.iter()) {
+            let Some(path) = linux_location_path(location) else {
+                continue;
+            };
+            match location.linux_dup_fd() {
+                Ok(fd) => {
+                    writable_owned.push(path);
+                    writable_fds.push(fd);
+                }
+                Err(error) => {
+                    // Fail closed: a bind we can't pin a verifiable fd for is
+                    // dropped rather than bound unverified.
+                    log::warn!(
+                        "[sandbox] could not duplicate fd for writable bind {}: {error}",
+                        path.display()
+                    );
+                }
+            }
+        }
+        let writable: Vec<&Path> = writable_owned.iter().map(PathBuf::as_path).collect();
+        let protected_owned: Vec = git_protected
             .iter()
-            .map(PathBuf::as_path)
+            .filter_map(linux_location_path)
             .collect();
-        writable.extend(git_writable.iter().map(PathBuf::as_path));
-        let protected_git_dirs: Vec<&Path> = git_protected.iter().map(PathBuf::as_path).collect();
+        let protected_git_dirs: Vec<&Path> = protected_owned.iter().map(PathBuf::as_path).collect();
+
+        // Stand up the host endpoint that sends the captured fds to the
+        // in-sandbox validator, when this run has writable binds to verify. It's
+        // an in-process background thread owned by this (per-command) `Sandbox`,
+        // so it lives only for the command's duration. The sender serves its
+        // descriptors to exactly one client and then tears itself down, so each
+        // wrap creates a fresh one (replacing any from a prior wrap); a
+        // `Sandbox` normally wraps a single command.
+        if !self.fs.allow_fs_write && !writable_fds.is_empty() {
+            let sender =
+                linux_bubblewrap::ValidationFdSender::spawn(writable_fds).map_err(|error| {
+                    SandboxError::Io(format!("failed to start sandbox bind validator: {error}"))
+                })?;
+            self.validation_fd_sender = Some(sender);
+        }
+        let validation_socket =
+            self.validation_fd_sender
+                .as_ref()
+                .map(|sender| linux_bubblewrap::ValidationSocket {
+                    host_socket_path: sender.host_socket_path(),
+                    sandbox_socket_path: sender.sandbox_socket_path(),
+                });
 
         let bridge_program = std::env::current_exe()
             .map_err(|error| SandboxError::BridgeExecutableUnavailable(error.to_string()))?;
@@ -547,6 +881,7 @@ impl Sandbox {
             &command.program,
             &command.args,
             proxy_socket_path.as_deref(),
+            validation_socket,
         )
         .map_err(map_anyhow_error)?;
 
@@ -576,14 +911,28 @@ impl Sandbox {
             allow_fs_write: self.fs.allow_fs_write,
         };
         let (git_writable, git_protected) = self.git_path_split();
+        // Each location's canonical path was resolved exactly once at capture
+        // time; pass it straight through as the Seatbelt rule literal. The
+        // profile generator must NOT re-canonicalize (that reopened the
+        // verify-vs-enforce gap); see `generate_seatbelt_config`.
         let mut writable: Vec<&Path> = self
             .fs
             .writable_paths
             .iter()
-            .map(PathBuf::as_path)
+            .map(HostFilesystemLocation::macos_canonical_path)
+            .collect();
+        writable.extend(
+            git_writable
+                .iter()
+                .map(HostFilesystemLocation::macos_canonical_path),
+        );
+        let protected: Vec<&Path> = git_protected
+            .iter()
+            .map(HostFilesystemLocation::macos_canonical_path)
             .collect();
-        writable.extend(git_writable.iter().map(PathBuf::as_path));
-        let protected: Vec<&Path> = git_protected.iter().map(PathBuf::as_path).collect();
+
+        // SSH-agent socket handling (commit signing) is deferred, so no unix
+        // sockets are allowed for now.
         let (program, args, config) = macos_seatbelt::wrap_invocation(
             &command.program,
             &command.args,
@@ -614,16 +963,34 @@ impl Sandbox {
             allow_network: matches!(self.network, NetSetup::Unrestricted),
             allow_fs_write: self.fs.allow_fs_write,
         };
-        let (writable_git_paths, protected_git_paths) = self.git_path_split();
+        // On Windows the location carries only the requested path; the in-WSL
+        // helper performs the real capture-at-validation. These are mapped into
+        // WSL by `wrap_invocation`.
+        let writable_paths: Vec = self
+            .fs
+            .writable_paths
+            .iter()
+            .map(|location| location.windows_path().to_path_buf())
+            .collect();
+        let (writable_git_locations, protected_git_locations) = self.git_path_split();
+        let writable_git_paths: Vec = writable_git_locations
+            .iter()
+            .map(|location| location.windows_path().to_path_buf())
+            .collect();
+        let protected_git_paths: Vec = protected_git_locations
+            .iter()
+            .map(|location| location.windows_path().to_path_buf())
+            .collect();
         let (program, args) = windows_wsl::wrap_invocation(
             command.program.clone(),
             command.args.clone(),
-            self.fs.writable_paths.clone(),
+            writable_paths,
             writable_git_paths,
             protected_git_paths,
             permissions,
             command.cwd.clone(),
             command.env.clone(),
+            self.wsl_zed_release.clone(),
         )
         .await
         .map_err(map_anyhow_error)?;
@@ -650,23 +1017,47 @@ impl Drop for Sandbox {
     }
 }
 
-/// Handle a possible re-exec of this binary as an in-sandbox helper.
+/// Argv flag that marks the WSL-side sandbox-helper re-exec. Shared so the
+/// Windows side (`windows_wsl`, which builds the `wsl.exe` invocation) and the
+/// Linux side (`linux_bubblewrap`, which parses it inside WSL) can't drift.
+///
+/// Only referenced by those two cfg-gated modules, so it's gated to match;
+/// otherwise it's dead code on macOS.
+#[cfg(any(target_os = "linux", target_os = "windows"))]
+pub(crate) const WSL_SANDBOX_HELPER_FLAG: &str = "--wsl-sandbox-helper";
+
+/// Handle a possible re-exec of this binary as a sandbox helper.
+///
+/// Two Linux re-exec modes funnel through here, neither of which returns if it
+/// matches:
+/// - the in-sandbox launcher (bind validator + restricted-network bridge), run
+///   by bwrap before the real command; and
+/// - the WSL-side helper, run inside WSL to capture fds + drive bwrap (the moral
+///   equivalent of `Sandbox::wrap` on native Linux).
 ///
-/// Linux restricted-network runs launch this binary in bridge mode inside the
-/// sandbox network namespace before spawning the real command. Call this at the
-/// top of `main`, before normal argument parsing.
+/// Call this at the top of `main`, before normal argument parsing.
 #[doc(hidden)]
 pub fn run_sandbox_launcher_if_invoked() {
     #[cfg(target_os = "linux")]
-    linux_bubblewrap::run_launcher_if_invoked();
+    {
+        linux_bubblewrap::run_launcher_if_invoked();
+        linux_bubblewrap::run_wsl_helper_if_invoked();
+    }
 }
 
+// The createability probe only needs to know whether a sandbox *can* be built
+// (namespaces, `bwrap` availability); it does not enforce any grant, so it runs
+// with no writable binds rather than re-deriving paths from the opaque
+// locations.
+
+/// The current path of the inode pinned by a [`HostFilesystemLocation`]'s
+/// `O_PATH` fd, via `/proc/self/fd`. This resolves to the *pinned inode*, so it
+/// reflects the object captured at validation even if its name was changed,
+/// rather than re-resolving an attacker-influenceable path string.
 #[cfg(target_os = "linux")]
-fn policy_writable_paths(policy: &SandboxPolicy) -> Vec {
-    match &policy.fs {
-        SandboxFsPolicy::Unrestricted => Vec::new(),
-        SandboxFsPolicy::Restricted { writable_paths } => writable_paths.clone(),
-    }
+fn linux_location_path(location: &HostFilesystemLocation) -> Option {
+    use std::os::fd::AsRawFd as _;
+    std::fs::read_link(format!("/proc/self/fd/{}", location.linux_fd().as_raw_fd())).ok()
 }
 
 #[cfg(not(target_os = "windows"))]
@@ -842,20 +1233,25 @@ mod tests {
 
     #[test]
     fn fs_merge_unrestricted_dominates_else_unions_paths() {
+        // Writable paths are captured as real `HostFilesystemLocation`s (keyed on
+        // the inode), so the union/dedup test needs three distinct real dirs.
+        let dir_a = tempfile::tempdir().expect("create temp dir a");
+        let dir_b = tempfile::tempdir().expect("create temp dir b");
+        let dir_c = tempfile::tempdir().expect("create temp dir c");
+        let location = |dir: &tempfile::TempDir| {
+            HostFilesystemLocation::new(dir.path()).expect("capture temp dir")
+        };
+
         let a = SandboxFsPolicy::Restricted {
-            writable_paths: vec![PathBuf::from("/a"), PathBuf::from("/b")],
+            writable_paths: vec![location(&dir_a), location(&dir_b)],
         };
         let b = SandboxFsPolicy::Restricted {
-            writable_paths: vec![PathBuf::from("/b"), PathBuf::from("/c")],
+            writable_paths: vec![location(&dir_b), location(&dir_c)],
         };
         assert_eq!(
             a.clone().merge(b),
             SandboxFsPolicy::Restricted {
-                writable_paths: vec![
-                    PathBuf::from("/a"),
-                    PathBuf::from("/b"),
-                    PathBuf::from("/c")
-                ],
+                writable_paths: vec![location(&dir_a), location(&dir_b), location(&dir_c)],
             }
         );
         assert_eq!(
@@ -864,7 +1260,7 @@ mod tests {
         );
         assert_eq!(
             SandboxFsPolicy::Unrestricted.merge(SandboxFsPolicy::Restricted {
-                writable_paths: vec![PathBuf::from("/a")],
+                writable_paths: vec![location(&dir_a)],
             }),
             SandboxFsPolicy::Unrestricted
         );
diff --git a/crates/sandbox/src/windows_wsl.rs b/crates/sandbox/src/windows_wsl.rs
index 03c1c457212ca6..212df7d47c28fd 100644
--- a/crates/sandbox/src/windows_wsl.rs
+++ b/crates/sandbox/src/windows_wsl.rs
@@ -58,6 +58,101 @@ const BWRAP_UNUSABLE_EXIT_CODE: i32 = 42;
 /// of any stdout noise printed by the login shell's profile scripts.
 const PROBE_RESULT_PREFIX: &str = "zed-wsl-probe:";
 
+/// Prefix of the helper-provisioning script's single result line (the absolute
+/// in-WSL path of the Linux `zed` to run as the sandbox helper), picked out of
+/// login-shell stdout noise just like [`PROBE_RESULT_PREFIX`].
+const HELPER_RESULT_PREFIX: &str = "zed-wsl-helper:";
+
+/// Ensures a Linux `zed` matching the running release is available inside WSL to
+/// act as the sandbox helper (`--wsl-sandbox-helper`), and prints its absolute
+/// in-WSL path on a [`HELPER_RESULT_PREFIX`] line. `$1` is the release channel,
+/// `$2` the version (`latest` for dev builds, which have no matching release and
+/// so track the latest nightly); both are passed as argv, never interpolated, so
+/// a version/channel string can't inject shell.
+///
+/// Unlike a normal Linux install, this deliberately does **not** consult the WSL
+/// `PATH`: inside WSL `zed` typically resolves to the *Windows* `zed.exe` via
+/// interop, which is not a Linux binary and so can't be the helper. It also does
+/// not use the public install script (`install.sh`), which puts `zed` on the
+/// user's `PATH` and writes desktop entries we don't want. Instead the Windows
+/// side resolves the exact channel+version (see `wsl_zed_release`) and this
+/// script downloads that release's Linux tarball straight from
+/// `cloud.zed.dev/releases` and unpacks it into a private, off-`PATH` location
+/// (`~/.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.
+///
+/// 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
+/// caller surfaces to the user, exactly like a missing `bwrap`.
+const HELPER_PROVISION_SCRIPT: &str = r#"
+set -eu
+channel="$1"
+version="$2"
+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)
+    if [ -n "$helper" ] && [ -x "$helper" ]; then
+        printf 'zed-wsl-helper: %s\n' "$helper"
+        exit 0
+    fi
+fi
+
+arch=$(uname -m)
+case "$arch" in
+    x86_64 | amd64) arch="x86_64" ;;
+    aarch64 | arm64) arch="aarch64" ;;
+    *) echo "unsupported WSL architecture for the zed sandbox helper: $arch" >&2; exit 1 ;;
+esac
+url="https://cloud.zed.dev/releases/$channel/$version/download?asset=zed&arch=$arch&os=linux&source=zed-wsl-sandbox"
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/zed-wsl-helper-XXXXXX")
+trap 'rm -rf "$tmp"' EXIT
+tarball="$tmp/zed.tar.gz"
+if command -v curl >/dev/null 2>&1; then
+    curl -fL "$url" -o "$tarball"
+elif command -v wget >/dev/null 2>&1; then
+    wget -O "$tarball" "$url"
+else
+    echo 'neither curl nor wget is available in WSL to download zed' >&2
+    exit 1
+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)
+if [ -z "$helper_src" ]; then
+    echo 'the downloaded zed tarball did not contain a bin/zed binary' >&2
+    exit 1
+fi
+app=$(dirname "$(dirname "$helper_src")")
+
+# Install atomically: stage the unpacked app next to the destination on the same
+# filesystem, then swap it into place so a concurrent run never sees (or execs) a
+# partially-written install. The whole app dir is kept so the binary's bundled
+# libraries ($ORIGIN/../lib) remain alongside it.
+mkdir -p "$(dirname "$dest")"
+rm -rf "$dest.new" "$dest.old"
+cp -a "$app" "$dest.new"
+if [ -e "$dest" ]; then mv "$dest" "$dest.old"; fi
+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)
+if [ -z "$helper" ] || [ ! -x "$helper" ]; then
+    echo "the installed zed sandbox helper is missing or not executable under $dest" >&2
+    exit 1
+fi
+printf 'zed-wsl-helper: %s\n' "$helper"
+exit 0
+"#;
+
 /// Marks a failure of the Windows WSL sandboxing *environment*: WSL is missing
 /// or won't start, there's no usable `bwrap`, or the probe / path-resolution
 /// stdout protocol broke down. Returned as the root of the `anyhow::Error` so
@@ -177,6 +272,13 @@ pub async fn wrap_invocation(
     permissions: SandboxPermissions,
     cwd: Option,
     env: HashMap,
+    // `(release channel, version)` of the Linux `zed` to provision inside WSL as
+    // the `--wsl-sandbox-helper` (the version is `latest` for dev builds). When
+    // `None`, no helper is used and bwrap is exec'd directly — the legacy path,
+    // which binds writable paths by string and so carries the bind-source TOCTOU
+    // the helper closes. Callers that can determine the running release should
+    // always pass `Some`.
+    wsl_zed_release: Option<(String, String)>,
 ) -> Result<(String, Vec)> {
     // Mapping failures are bad requests (a path that doesn't exist or has a
     // shape WSL can't address), not environment problems, so no
@@ -279,21 +381,50 @@ pub async fn wrap_invocation(
     if let Some(cwd) = &cwd {
         wsl_args.extend(["--cd".to_string(), cwd.clone()]);
     }
-    // Use the absolute path the probe validated: `wsl --exec` searches only
-    // the default WSL PATH, which may not include a profile-managed location
-    // where the probe's login shell found `bwrap`.
-    wsl_args.extend(["--exec".to_string(), environment.bwrap_path.clone()]);
-    wsl_args.extend(build_bwrap_args(
+
+    // The bwrap *options* (everything before the trailing `-- cmd`): root bind,
+    // `/tmp` tmpfs, writable binds, interop blocking, `--setenv`s, `--chdir`,
+    // namespace flags. Identical whether or not we route through the helper.
+    let bwrap_options = build_bwrap_args(
         &writable_paths,
         &protected_git_paths,
         permissions,
         cwd.as_deref(),
         environment.mask_interop_dir,
         &env,
-    ));
-    wsl_args.push("--".to_string());
-    wsl_args.push(program);
-    wsl_args.extend(args);
+    );
+
+    match wsl_zed_release {
+        // Preferred path: run the in-WSL `zed` as the sandbox helper, which
+        // captures the writable binds' inodes WSL-side and validates them after
+        // bwrap's mounts (the same in-sandbox check native Linux performs).
+        Some((channel, version)) => {
+            let helper =
+                ensure_wsl_zed_helper(&wsl_exe, distro.as_deref(), &channel, &version).await?;
+            wsl_args.extend(["--exec".to_string(), helper]);
+            // Protocol (decoded by `linux_bubblewrap::decode_wsl_helper_args`):
+            //         --  
+            wsl_args.push(crate::WSL_SANDBOX_HELPER_FLAG.to_string());
+            wsl_args.push(environment.bwrap_path.clone());
+            wsl_args.push(bwrap_options.len().to_string());
+            wsl_args.extend(bwrap_options);
+            wsl_args.push(writable_paths.len().to_string());
+            wsl_args.extend(writable_paths.iter().cloned());
+            wsl_args.push("--".to_string());
+            wsl_args.push(program);
+            wsl_args.extend(args);
+        }
+        // Legacy path: exec bwrap directly (no in-sandbox bind validation). Use
+        // the absolute path the probe validated, since `wsl --exec` searches
+        // only the default WSL PATH.
+        None => {
+            wsl_args.extend(["--exec".to_string(), environment.bwrap_path.clone()]);
+            wsl_args.extend(bwrap_options);
+            wsl_args.push("--".to_string());
+            wsl_args.push(program);
+            wsl_args.extend(args);
+        }
+    }
 
     Ok((wsl_exe.to_string_lossy().into_owned(), wsl_args))
 }
@@ -509,6 +640,94 @@ fn parse_probe_output(stdout: &str) -> Result {
     })
 }
 
+/// Ensure a Linux `zed` of the given release `channel`/`version` is available
+/// inside WSL and return its absolute in-WSL path, to be `--exec`'d as the
+/// `--wsl-sandbox-helper`. Runs [`HELPER_PROVISION_SCRIPT`] (which downloads the
+/// matching release tarball into an off-`PATH` location on first use).
+///
+/// Successful resolutions are cached per `(distro, channel, version)` for the
+/// life of the process — once provisioned, the path won't change. Failures are
+/// not cached, so a user who installs `curl` (or fixes networking) after an
+/// error can retry without restarting Zed.
+async fn ensure_wsl_zed_helper(
+    wsl_exe: &Path,
+    distro: Option<&str>,
+    channel: &str,
+    version: &str,
+) -> Result {
+    type HelperCache = HashMap<(Option, String, String), String>;
+    static CACHE: OnceLock> = OnceLock::new();
+    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
+
+    let key = (
+        distro.map(str::to_string),
+        channel.to_string(),
+        version.to_string(),
+    );
+    if let Some(path) = cache
+        .lock()
+        .unwrap_or_else(|poisoned| poisoned.into_inner())
+        .get(&key)
+    {
+        return Ok(path.clone());
+    }
+
+    // A login shell (`-lc`) is used so a profile-managed PATH (where `zed` or
+    // `curl` may live) is honored. `channel`/`version` are passed as positional
+    // args (`$1`/`$2`), never interpolated into the script body.
+    let output = run_wsl_command(
+        wsl_exe,
+        distro,
+        [
+            "--exec",
+            "sh",
+            "-lc",
+            HELPER_PROVISION_SCRIPT,
+            "zed-wsl-sandbox-helper",
+            channel,
+            version,
+        ],
+        "provision the Linux `zed` sandbox helper",
+    )
+    .await?;
+    if !output.status.success() {
+        let stderr = String::from_utf8_lossy(&output.stderr);
+        let stderr = stderr.trim();
+        return Err(unavailable(format!(
+            "failed to provision a Linux `zed` sandbox helper in {}{}",
+            wsl_distro_label(distro),
+            if stderr.is_empty() {
+                String::new()
+            } else {
+                format!(": {stderr}")
+            }
+        )));
+    }
+
+    let stdout = String::from_utf8_lossy(&output.stdout);
+    let path = stdout
+        .lines()
+        .rev()
+        .find_map(|line| line.strip_prefix(HELPER_RESULT_PREFIX))
+        .map(|path| path.trim().to_string())
+        .with_context(|| {
+            unavailable(format!(
+                "no helper result line in sandbox-helper provisioning output from {}: {stdout:?}",
+                wsl_distro_label(distro)
+            ))
+        })?;
+    ensure!(
+        path.starts_with('/'),
+        "the WSL `zed` sandbox helper resolved to {path:?} rather than an absolute path"
+    );
+
+    cache
+        .lock()
+        .unwrap_or_else(|poisoned| poisoned.into_inner())
+        .insert(key, path.clone());
+    Ok(path)
+}
+
 /// Shell script that resolves and existence-checks paths in a single WSL
 /// round-trip. Arguments come in triples `(kind, path, fallback)`: kind `W`
 /// is a native Windows path to translate with `wslpath -u` (falling back to
@@ -1105,6 +1324,7 @@ mod tests {
             SandboxPermissions::default(),
             None,
             HashMap::::new(),
+            None,
         ));
     }
 
diff --git a/crates/sandbox/src/wsl_sandbox_test_helper.rs b/crates/sandbox/src/wsl_sandbox_test_helper.rs
index b4fab4cbcd24ff..d184d09ef42f5f 100644
--- a/crates/sandbox/src/wsl_sandbox_test_helper.rs
+++ b/crates/sandbox/src/wsl_sandbox_test_helper.rs
@@ -48,8 +48,8 @@ mod imp {
 
     use anyhow::{Context as _, Result, bail, ensure};
     use sandbox::{
-        CommandAndArgs, GitSandboxPolicy, Sandbox, SandboxError, SandboxFsPolicy, SandboxNetPolicy,
-        SandboxPolicy,
+        CommandAndArgs, GitSandboxPolicy, HostFilesystemLocation, Sandbox, SandboxError,
+        SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy,
     };
 
     /// Network access for a helper run, translated into a `SandboxNetPolicy` in
@@ -513,7 +513,12 @@ mod imp {
                 SandboxFsPolicy::Unrestricted
             } else {
                 SandboxFsPolicy::Restricted {
-                    writable_paths: writable_paths.to_vec(),
+                    // On Windows the location only records the requested path;
+                    // the real capture happens WSL-side in the helper.
+                    writable_paths: writable_paths
+                        .iter()
+                        .filter_map(|path| HostFilesystemLocation::new(path).ok())
+                        .collect(),
                 }
             },
             network: match permissions.network {
diff --git a/docs/README.md b/docs/README.md
index 65c4699cb626a9..faf443bef51af0 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -13,6 +13,17 @@ mdbook serve docs
 
 The first command dumps an action manifest to `crates/docs_preprocessor/actions.json`. Without it, the preprocessor cannot validate keybinding and action references in the docs and will report errors. You only need to re-run it when actions change.
 
+If you use Nix, the development shell provides a pinned `mdbook` (0.4.40) and a
+prebuilt docs preprocessor, so you can build the docs without installing anything
+or compiling the preprocessor on every run:
+
+```sh
+nix develop -c mdbook build docs
+```
+
+(When `actions.json` has not been generated, action/keybinding validation is
+skipped with a warning rather than failing the build.)
+
 It's important to note the version number above. For an unknown reason, as of 2025-04-23, running 0.4.48 will cause odd URL behavior that breaks things.
 
 Before committing, verify that the docs are formatted in the way Prettier expects with:
@@ -25,7 +36,7 @@ cd docs && pnpm dlx prettier@3.5.0 . --write && cd ..
 
 We have a custom mdBook preprocessor for interfacing with our crates (`crates/docs_preprocessor`).
 
-If for some reason you need to bypass the docs preprocessor, you can comment out `[preprocessor.zed_docs_preprocessor]` from the `book.toml`.
+If for some reason you need to bypass the docs preprocessor, you can comment out `[preprocessor.zed-docs-preprocessor]` from the `book.toml`.
 
 ## Images and videos
 
diff --git a/docs/book.toml b/docs/book.toml
index f3ebf4187ed4ea..2eed5f7907a668 100644
--- a/docs/book.toml
+++ b/docs/book.toml
@@ -106,6 +106,6 @@ enable = false
 # and other docs-related functions.
 #
 # Comment the below section out if you need to bypass the preprocessor for some reason.
-[preprocessor.zed_docs_preprocessor]
+[preprocessor.zed-docs-preprocessor]
 command = "cargo run -p docs_preprocessor --"
 renderer = ["html", "zed-html"]
diff --git a/nix/build.nix b/nix/build.nix
index 0375b0de5f816d..c01d869fddc9fd 100644
--- a/nix/build.nix
+++ b/nix/build.nix
@@ -311,6 +311,13 @@ craneLib.buildPackage (
   lib.recursiveUpdate commonArgs {
     inherit cargoArtifacts;
 
+    # Expose the crane builder and shared arguments so other derivations (e.g.
+    # the docs preprocessor in the devshell) can build sibling workspace crates
+    # without duplicating all of the build inputs and environment setup.
+    passthru = {
+      inherit craneLib commonArgs cargoArtifacts;
+    };
+
     dontUseCmakeConfigure = true;
 
     # without the env var generate-licenses fails due to crane's fetchCargoVendor, see:
diff --git a/nix/dev/flake.lock b/nix/dev/flake.lock
index bdb585dde4ca50..775903fce82756 100644
--- a/nix/dev/flake.lock
+++ b/nix/dev/flake.lock
@@ -16,8 +16,25 @@
         "type": "github"
       }
     },
+    "nixpkgs-mdbook": {
+      "locked": {
+        "lastModified": 1721153957,
+        "narHash": "sha256-3xc3Tvk+AivI7TKctzgg3Q/4cLeqtarUuWLgTv2rPPM=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "6ecabf9e3f617aeec1a23a27d0080cab066a9d5b",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "6ecabf9e3f617aeec1a23a27d0080cab066a9d5b",
+        "type": "github"
+      }
+    },
     "root": {
       "inputs": {
+        "nixpkgs-mdbook": "nixpkgs-mdbook",
         "treefmt-nix": "treefmt-nix"
       }
     },
diff --git a/nix/dev/flake.nix b/nix/dev/flake.nix
index b508417b62c601..d430020226a395 100644
--- a/nix/dev/flake.nix
+++ b/nix/dev/flake.nix
@@ -3,6 +3,10 @@
 
   inputs = {
     treefmt-nix.url = "github:numtide/treefmt-nix";
+    # Pinned to a nixpkgs revision that packages mdBook 0.4.40, the version the
+    # docs require (see `crates/docs_preprocessor/Cargo.toml`). Newer mdBook
+    # releases break the docs' double-nested subdirectories.
+    nixpkgs-mdbook.url = "github:NixOS/nixpkgs/6ecabf9e3f617aeec1a23a27d0080cab066a9d5b";
   };
 
   # This flake is only used for its inputs.
diff --git a/nix/modules/devshells.nix b/nix/modules/devshells.nix
index da6ba6fd1134d4..62b80a8d9e0531 100644
--- a/nix/modules/devshells.nix
+++ b/nix/modules/devshells.nix
@@ -1,13 +1,41 @@
 { inputs, ... }:
 {
   perSystem =
-    { pkgs, ... }:
+    { pkgs, system, ... }:
     let
       # NOTE: Duplicated because this is in a separate flake-parts partition
       # than ./packages.nix
       mkZed = import ../toolchain.nix { inherit inputs; };
       zed-editor = mkZed pkgs;
 
+      # mdBook pinned to 0.4.40 via a dedicated nixpkgs input, because the docs
+      # rely on behavior that newer mdBook releases break (see
+      # `crates/docs_preprocessor/Cargo.toml`).
+      mdbook = (import inputs.nixpkgs-mdbook { inherit system; }).mdbook;
+
+      # Prebuilt docs preprocessor/postprocessor binary. `docs/book.toml`
+      # defaults to `cargo run -p docs_preprocessor` so non-Nix contributors are
+      # unaffected; in the devshell we point mdBook at this prebuilt binary via
+      # the `MDBOOK_*` env vars below so `mdbook build docs` doesn't have to
+      # compile the preprocessor on every run.
+      #
+      # We reuse `zed-editor`'s crane builder and shared arguments (exposed via
+      # `passthru`) rather than `overrideAttrs`, because crane bakes
+      # `cargoExtraArgs` into the build command at evaluation time.
+      docs-preprocessor = zed-editor.passthru.craneLib.buildPackage (
+        zed-editor.passthru.commonArgs
+        // {
+          inherit (zed-editor.passthru) cargoArtifacts;
+          pname = "zed-docs-preprocessor";
+          cargoExtraArgs = "-p docs_preprocessor --locked";
+          dontUseCmakeConfigure = true;
+          meta = {
+            description = "mdBook preprocessor and postprocessor for the Zed docs";
+            mainProgram = "docs_preprocessor";
+          };
+        }
+      );
+
       rustBin = inputs.rust-overlay.lib.mkRustBin { } pkgs;
       rustToolchain = rustBin.fromRustupToolchainFile ../../rust-toolchain.toml;
 
@@ -54,6 +82,10 @@
             nodejs_22
             zig
 
+            # Documentation tooling: `nix develop -c mdbook build docs`
+            mdbook
+            docs-preprocessor
+
             # A11y testing infra
             gobject-introspection
             at-spi2-core
@@ -81,6 +113,14 @@
               ];
             };
             PROTOC = "${pkgs.protobuf}/bin/protoc";
+
+            # Point mdBook at the prebuilt preprocessor/postprocessor binary
+            # instead of `cargo run`. mdBook lowercases these keys and turns `_`
+            # into `-`, so they map to `preprocessor.zed-docs-preprocessor.command`
+            # and `output.zed-html.command` in `docs/book.toml`.
+            MDBOOK_PREPROCESSOR__ZED_DOCS_PREPROCESSOR__COMMAND = "${docs-preprocessor}/bin/docs_preprocessor";
+            MDBOOK_OUTPUT__ZED_HTML__COMMAND = "${docs-preprocessor}/bin/docs_preprocessor postprocess";
+
             ZED_ZSTD_MUSL_LIB = "${pkgs.pkgsCross.musl64.pkgsStatic.zstd.out}/lib";
             # For aws-lc-sys musl cross-compilation
             CC_x86_64_unknown_linux_musl = "${muslCross.stdenv.cc}/bin/x86_64-unknown-linux-musl-gcc";
diff --git a/nix/tests/sandboxing/default.nix b/nix/tests/sandboxing/default.nix
index 22eaf10357bce6..eb4fbad82b0757 100644
--- a/nix/tests/sandboxing/default.nix
+++ b/nix/tests/sandboxing/default.nix
@@ -31,6 +31,11 @@
 #   writablePaths = [ ];        # writable subtrees when fs = "restricted"
 #   networkAccess = "blocked";  # or "unrestricted" / "restricted"
 #   allowedDomains = [ ];       # allowed hosts when networkAccess = "restricted"
+#   gitDisabled = [ ];          # `.git` dirs whose contents are protected
+#                               # (read-only on Linux). Mutually exclusive with
+#                               # gitAllowed.
+#   gitAllowed = [ ];           # `.git` dirs whose contents are made writable.
+#                               # Mutually exclusive with gitDisabled.
 #
 # Two echo servers (`echo1`, `echo2`) on separate nodes give the network checks
 # real peers, so a restricted-network policy that allowlists `echo1` can be
@@ -232,6 +237,138 @@ in
         succeeds = true;
       }
 
+      # ---- Git protection ----------------------------------------------------
+      # The worktree is writable, but the `.git` directory inside it can be
+      # protected (Git disabled) or opened up (Git allowed) independently. On
+      # Linux a protected `.git` is re-bound read-only *over* the writable
+      # worktree, so its contents stay readable but writes are denied.
+
+      # Git disabled: the worktree is writable but `.git` is protected, so a
+      # write into `.git` is denied. This is the core case the feature exists
+      # for — an agent editing the project must not be able to corrupt Git
+      # metadata.
+      {
+        fs = "restricted";
+        writablePaths = [ "/sandbox-test/repo" ];
+        gitDisabled = [ "/sandbox-test/repo/.git" ];
+        networkAccess = "blocked";
+        write = "/sandbox-test/repo/.git/test";
+        succeeds = false;
+      }
+
+      # Git allowed: the same write into `.git` now succeeds because the policy
+      # makes `.git` writable.
+      {
+        fs = "restricted";
+        writablePaths = [ "/sandbox-test/repo" ];
+        gitAllowed = [ "/sandbox-test/repo/.git" ];
+        networkAccess = "blocked";
+        write = "/sandbox-test/repo/.git/test";
+        succeeds = true;
+      }
+
+      # Git disabled protects only `.git`: ordinary writes elsewhere in the
+      # writable worktree are unaffected.
+      {
+        fs = "restricted";
+        writablePaths = [ "/sandbox-test/repo" ];
+        gitDisabled = [ "/sandbox-test/repo/.git" ];
+        networkAccess = "blocked";
+        write = "/sandbox-test/repo/src/main.rs";
+        succeeds = true;
+      }
+
+      # Git disabled is a subtree protection: a write to something nested deep
+      # inside `.git` is denied too, not just a top-level file.
+      {
+        fs = "restricted";
+        writablePaths = [ "/sandbox-test/repo" ];
+        gitDisabled = [ "/sandbox-test/repo/.git" ];
+        networkAccess = "blocked";
+        write = "/sandbox-test/repo/.git/hooks/pre-commit";
+        succeeds = false;
+      }
+
+      # Git disabled blocks writes but NOT reads: on Linux a protected `.git` is
+      # read-only, so its contents remain readable from inside the sandbox
+      # (Git status, log, diff, … must keep working).
+      {
+        fs = "restricted";
+        writablePaths = [ "/sandbox-test/repo" ];
+        gitDisabled = [ "/sandbox-test/repo/.git" ];
+        networkAccess = "blocked";
+        read = "/sandbox-test/repo/.git/HEAD";
+        succeeds = true;
+      }
+
+      # Git allowed grants writes to `.git` on its own, even when the worktree
+      # itself is not in `writablePaths`. The policy's Git dirs are made
+      # writable directly.
+      {
+        fs = "restricted";
+        writablePaths = [ ];
+        gitAllowed = [ "/sandbox-test/repo/.git" ];
+        networkAccess = "blocked";
+        write = "/sandbox-test/repo/.git/test";
+        succeeds = true;
+      }
+
+      # ...and that grant is scoped to `.git`: it does not make the surrounding
+      # worktree writable. A write next to `.git` (not in `writablePaths`) is
+      # still denied.
+      {
+        fs = "restricted";
+        writablePaths = [ ];
+        gitAllowed = [ "/sandbox-test/repo/.git" ];
+        networkAccess = "blocked";
+        write = "/sandbox-test/repo/README.md";
+        succeeds = false;
+      }
+
+      # Multiple repositories: each protected `.git` is independently denied.
+      # This exercises the list handling — a write into the *second* repo's
+      # `.git` must still be blocked.
+      {
+        fs = "restricted";
+        writablePaths = [
+          "/sandbox-test/repo-a"
+          "/sandbox-test/repo-b"
+        ];
+        gitDisabled = [
+          "/sandbox-test/repo-a/.git"
+          "/sandbox-test/repo-b/.git"
+        ];
+        networkAccess = "blocked";
+        write = "/sandbox-test/repo-b/.git/test";
+        succeeds = false;
+      }
+
+      # The fs escape hatch supersedes Git protection: when filesystem writes
+      # are unrestricted there is no read-only bind to protect `.git`, so the
+      # write succeeds. This documents that `gitDisabled` only has teeth under a
+      # restricted filesystem policy.
+      {
+        fs = "unrestricted";
+        gitDisabled = [ "/sandbox-test/repo/.git" ];
+        networkAccess = "blocked";
+        write = "/sandbox-test/repo/.git/test";
+        succeeds = true;
+      }
+
+      # Documented Linux gap: a `.git` that does not yet exist when the sandbox
+      # is built cannot be re-bound read-only, so it is skipped. Writing the
+      # `.git` entry itself (its parent worktree is writable, and `.git` does
+      # not exist beforehand) therefore succeeds. macOS denies this even before
+      # `.git` exists; bwrap cannot, and this test pins that difference.
+      {
+        fs = "restricted";
+        writablePaths = [ "/sandbox-test/fresh-repo" ];
+        gitDisabled = [ "/sandbox-test/fresh-repo/.git" ];
+        networkAccess = "blocked";
+        write = "/sandbox-test/fresh-repo/.git";
+        succeeds = true;
+      }
+
       # Blocked network: the echo server is unreachable.
       {
         fs = "restricted";

From e783b2f063167fc9dd2100b5ef77b2da49b63e8b Mon Sep 17 00:00:00 2001
From: Ben Brandt 
Date: Tue, 30 Jun 2026 15:03:45 +0200
Subject: [PATCH 002/422] acp_thread: Defer status updates until turn
 completion (#60148)

We were signaling this too early, causing the syncing generator to get
updated too soon.

Release Notes:

- N/A
---
 crates/acp_thread/src/acp_thread.rs      | 13 ++++-
 crates/agent_ui/src/conversation_view.rs | 67 ++++++++++++++++++++++++
 2 files changed, 79 insertions(+), 1 deletion(-)

diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs
index d060ecc26a6d1a..e85a0bd48fb95f 100644
--- a/crates/acp_thread/src/acp_thread.rs
+++ b/crates/acp_thread/src/acp_thread.rs
@@ -3296,10 +3296,12 @@ impl AcpThread {
                 // state even when the send_task is cancelled before tx.send().
                 if is_same_turn {
                     this.running_turn.take();
-                    cx.emit(AcpThreadEvent::StatusChanged);
                 }
 
                 let Ok(response) = response else {
+                    if is_same_turn {
+                        cx.emit(AcpThreadEvent::StatusChanged);
+                    }
                     // tx dropped, just return
                     return Ok(None);
                 };
@@ -3309,6 +3311,9 @@ impl AcpThread {
                         Self::flush_streaming_text(&mut this.streaming_text_buffer, cx);
 
                         if r.stop_reason == acp::StopReason::MaxTokens {
+                            if is_same_turn {
+                                cx.emit(AcpThreadEvent::StatusChanged);
+                            }
                             this.had_error = true;
                             cx.emit(AcpThreadEvent::Error);
                             log::error!("Max tokens reached. Usage: {:?}", this.token_usage);
@@ -3387,10 +3392,16 @@ impl AcpThread {
                             cx.emit(AcpThreadEvent::TokenUsageUpdated);
                         }
 
+                        if is_same_turn {
+                            cx.emit(AcpThreadEvent::StatusChanged);
+                        }
                         cx.emit(AcpThreadEvent::Stopped(r.stop_reason));
                         Ok(Some(r))
                     }
                     Err(e) => {
+                        if is_same_turn {
+                            cx.emit(AcpThreadEvent::StatusChanged);
+                        }
                         Self::flush_streaming_text(&mut this.streaming_text_buffer, cx);
                         if is_same_turn {
                             this.mark_pending_entries_as_canceled(cx);
diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs
index 43b10a4bd0074d..5267e7adfa6eca 100644
--- a/crates/agent_ui/src/conversation_view.rs
+++ b/crates/agent_ui/src/conversation_view.rs
@@ -4906,6 +4906,66 @@ pub(crate) mod tests {
         setup_conversation_view_with_initial_content_opt(agent, None, cx).await
     }
 
+    #[gpui::test]
+    async fn test_completed_plan_snapshot_keeps_list_state_in_sync(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let connection = StubAgentConnection::new();
+        let (conversation_view, cx) =
+            setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await;
+
+        message_editor(&conversation_view, cx).update_in(cx, |editor, window, cx| {
+            editor.set_text("Hello", window, cx);
+        });
+        active_thread(&conversation_view, cx).update_in(cx, |view, window, cx| {
+            view.send(window, cx);
+        });
+        cx.run_until_parked();
+
+        let session_id = active_thread(&conversation_view, cx).read_with(cx, |view, cx| {
+            assert_thread_list_item_count_matches_entries(view, cx);
+            view.thread.read(cx).session_id().clone()
+        });
+
+        cx.update(|_, cx| {
+            connection.send_update(
+                session_id.clone(),
+                acp::SessionUpdate::Plan(acp::Plan::new(vec![acp::PlanEntry::new(
+                    "Do the thing",
+                    acp::PlanEntryPriority::Medium,
+                    acp::PlanEntryStatus::InProgress,
+                )])),
+                cx,
+            );
+        });
+        cx.run_until_parked();
+        active_thread(&conversation_view, cx).read_with(cx, |view, cx| {
+            assert_thread_list_item_count_matches_entries(view, cx);
+        });
+
+        cx.update(|_, cx| {
+            connection.send_update(
+                session_id.clone(),
+                acp::SessionUpdate::Plan(acp::Plan::new(vec![acp::PlanEntry::new(
+                    "Do the thing",
+                    acp::PlanEntryPriority::Medium,
+                    acp::PlanEntryStatus::Completed,
+                )])),
+                cx,
+            );
+        });
+        cx.run_until_parked();
+        active_thread(&conversation_view, cx).read_with(cx, |view, cx| {
+            assert_thread_list_item_count_matches_entries(view, cx);
+        });
+
+        connection.end_turn(session_id, acp::StopReason::EndTurn);
+        cx.run_until_parked();
+        active_thread(&conversation_view, cx).read_with(cx, |view, cx| {
+            assert_thread_list_item_count_matches_entries(view, cx);
+        });
+    }
+
     async fn setup_conversation_view_with_initial_content(
         agent: impl AgentServer + 'static,
         initial_content: AgentInitialContent,
@@ -5557,6 +5617,13 @@ pub(crate) mod tests {
         })
     }
 
+    fn assert_thread_list_item_count_matches_entries(view: &ThreadView, cx: &App) {
+        assert_eq!(
+            view.list_state.item_count(),
+            view.thread.read(cx).entries().len() + usize::from(view.generating_indicator_in_list)
+        );
+    }
+
     fn message_editor(
         conversation_view: &Entity,
         cx: &TestAppContext,

From 56562c28b0eb1e07af9f348c417b1c47419fdac1 Mon Sep 17 00:00:00 2001
From: Bennet Bo Fenner 
Date: Tue, 30 Jun 2026 16:50:01 +0200
Subject: [PATCH 003/422] settings_ui: Make agent settings searchable (#60151)

Right now subpages are not searchable. Since we don't have the bandwidth
to implement this today, this PR adds support for having aliases users
can search for. E.g. if I search for `claude` I will see the LLM
provider subpage, whereas previously I would not see anything.


https://github.com/user-attachments/assets/25af5c5a-86d2-473b-b0dd-11141b12a089

Release Notes:

- N/A
---
 crates/settings_ui/src/page_data.rs   | 57 +++++++++++++++++++++++++++
 crates/settings_ui/src/settings_ui.rs | 14 ++++---
 2 files changed, 66 insertions(+), 5 deletions(-)

diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs
index 5434016c50cbb1..4d1101d7186ef5 100644
--- a/crates/settings_ui/src/page_data.rs
+++ b/crates/settings_ui/src/page_data.rs
@@ -94,6 +94,7 @@ fn developer_page(cx: &App) -> SettingsPage {
             title: "Feature Flags".into(),
             r#type: Default::default(),
             description: None,
+            search_aliases: &[],
             json_path: Some("feature_flags"),
             in_json: true,
             files: USER,
@@ -3376,6 +3377,7 @@ fn languages_and_tools_page(cx: &App) -> SettingsPage {
                     title: language_name,
                     r#type: crate::SubPageType::Language,
                     description: None,
+                    search_aliases: &[],
                     json_path: Some(link.leak()),
                     in_json: true,
                     files: USER | PROJECT,
@@ -7990,6 +7992,32 @@ fn ai_page(cx: &App) -> SettingsPage {
                 r#type: Default::default(),
                 json_path: Some("llm_providers"),
                 description: Some("Configure API keys and settings for LLM providers.".into()),
+                search_aliases: &[
+                    "ai",
+                    "amazon",
+                    "anthropic",
+                    "api key",
+                    "azure",
+                    "bedrock",
+                    "chat",
+                    "claude",
+                    "copilot",
+                    "gemini",
+                    "github",
+                    "google",
+                    "gpt",
+                    "grok",
+                    "llama",
+                    "llm",
+                    "lm studio",
+                    "mistral",
+                    "ollama",
+                    "openai",
+                    "opencode",
+                    "provider",
+                    "vercel",
+                    "xai",
+                ],
                 in_json: false,
                 files: USER,
                 render: render_llm_providers_page,
@@ -8002,6 +8030,7 @@ fn ai_page(cx: &App) -> SettingsPage {
                 r#type: Default::default(),
                 json_path: Some(zed_actions::AGENT_SKILLS_SETTINGS_PATH),
                 description: Some("View and manage agent skills installed globally or in project worktrees.".into()),
+                search_aliases: &["agent skill", "agent skills", "custom instructions", "skill", "skills"],
                 in_json: false,
                 files: USER | PROJECT,
                 render: render_skills_setup_page,
@@ -8014,6 +8043,15 @@ fn ai_page(cx: &App) -> SettingsPage {
                     "Review and change the elevated terminal sandbox permissions that are always allowed without prompting."
                         .into(),
                 ),
+                search_aliases: &[
+                    "allow",
+                    "domain",
+                    "filesystem",
+                    "network",
+                    "sandbox",
+                    "unsandboxed",
+                    "permissions",
+                ],
                 in_json: true,
                 files: USER,
                 render: render_sandbox_settings_page,
@@ -8023,6 +8061,7 @@ fn ai_page(cx: &App) -> SettingsPage {
                 r#type: Default::default(),
                 json_path: Some("agent.tool_permissions"),
                 description: Some("Set up regex patterns to auto-allow, auto-deny, or always request confirmation, for specific tool inputs.".into()),
+                search_aliases: &[],
                 in_json: true,
                 files: USER,
                 render: render_tool_permissions_setup_page,
@@ -8037,6 +8076,7 @@ fn ai_page(cx: &App) -> SettingsPage {
                 description: Some(
                     "View, add, configure, and remove Model Context Protocol servers.".into(),
                 ),
+                search_aliases: &["context server", "mcp", "model context protocol"],
                 in_json: false,
                 files: USER,
                 render: render_mcp_servers_page,
@@ -8049,6 +8089,22 @@ fn ai_page(cx: &App) -> SettingsPage {
                     "View, add, and remove agents connected through the Agent Client Protocol."
                         .into(),
                 ),
+                search_aliases: &[
+                    "acp",
+                    "agent client protocol",
+                    "amp",
+                    "claude agent",
+                    "claude code",
+                    "codex",
+                    "copilot cli",
+                    "cursor",
+                    "external agent",
+                    "factory droid",
+                    "github copilot",
+                    "grok build",
+                    "junie",
+                    "opencode",
+                ],
                 in_json: false,
                 files: USER,
                 render: render_external_agents_page,
@@ -10374,6 +10430,7 @@ fn edit_prediction_language_settings_section() -> [SettingsPageItem; 5] {
             r#type: Default::default(),
             json_path: Some("edit_predictions.providers"),
             description: Some("Set up different edit prediction providers in complement to Zed's built-in Zeta model.".into()),
+            search_aliases: &[],
             in_json: false,
             files: USER,
             render: render_edit_prediction_setup_page
diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs
index 4847d066bae128..991789eb5a7711 100644
--- a/crates/settings_ui/src/settings_ui.rs
+++ b/crates/settings_ui/src/settings_ui.rs
@@ -1585,6 +1585,7 @@ struct SubPageLink {
     title: SharedString,
     r#type: SubPageType,
     description: Option,
+    search_aliases: &'static [&'static str],
     /// See [`SettingField.json_path`]
     json_path: Option<&'static str>,
     /// Whether or not the settings in this sub page are configurable in settings.json
@@ -2351,19 +2352,20 @@ impl SettingsWindow {
                     }
                     SettingsPageItem::SubPageLink(sub_page_link) => {
                         json_path = sub_page_link.json_path;
+                        let mut parts = vec![page.title, header_str, sub_page_link.title.as_ref()];
+                        parts.extend(sub_page_link.search_aliases);
                         documents.push(SearchDocument {
                             id: key_index,
-                            words: split_into_words(&[
-                                page.title,
-                                header_str,
-                                sub_page_link.title.as_ref(),
-                            ]),
+                            words: split_into_words(&parts),
                         });
                         push_candidates(
                             &mut fuzzy_match_candidates,
                             key_index,
                             sub_page_link.title.as_ref(),
                         );
+                        for alias in sub_page_link.search_aliases {
+                            push_candidates(&mut fuzzy_match_candidates, key_index, alias);
+                        }
                     }
                     SettingsPageItem::ActionLink(action_link) => {
                         documents.push(SearchDocument {
@@ -4128,6 +4130,7 @@ impl SettingsWindow {
             title: title.into(),
             r#type: SubPageType::default(),
             description: None,
+            search_aliases: &[],
             json_path,
             in_json,
             files: USER,
@@ -4185,6 +4188,7 @@ impl SettingsWindow {
             title: "Create Skill".into(),
             r#type: SubPageType::SkillCreator,
             description: None,
+            search_aliases: &[],
             json_path: None,
             in_json: false,
             files: USER | PROJECT,

From dfb70de652bb27451abc6ee6274bfabdb7524d36 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=B4me=20MEVEL?=
 <2572775+jmevel@users.noreply.github.com>
Date: Tue, 30 Jun 2026 17:11:36 +0200
Subject: [PATCH 004/422] helix: Add `z c` keymap to `ScrollCursorCenter`
 (#58660)

Helix's keymap to `Vertically center the line` (`align_view_center`
command) is `zc`
([documentation](https://docs.helix-editor.com/keymap.html#view-mode)).

Release Notes:

- Added Helix's `z c` keymap to editor, project panel and outline panel
to scroll cursor to center
---
 assets/keymaps/vim.json | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json
index 0258f34758e8b9..9787a9278410eb 100644
--- a/assets/keymaps/vim.json
+++ b/assets/keymaps/vim.json
@@ -550,6 +550,9 @@
       "space y": "editor::Copy",
       "space /": "pane::DeploySearch",
 
+      // View mode
+      "z c": "editor::ScrollCursorCenter",
+
       // Debug mode (Helix space G)
       "space shift-g l": "debugger::Start",
       "space shift-g r": "debugger::Restart",
@@ -1007,6 +1010,7 @@
       "ctrl-d": "project_panel::ScrollDown",
       "z t": "project_panel::ScrollCursorTop",
       "z z": "project_panel::ScrollCursorCenter",
+      "z c": "project_panel::ScrollCursorCenter",
       "z b": "project_panel::ScrollCursorBottom",
       "0": ["vim::Number", 0],
       "1": ["vim::Number", 1],
@@ -1038,6 +1042,7 @@
       "ctrl-d": "outline_panel::ScrollDown",
       "z t": "outline_panel::ScrollCursorTop",
       "z z": "outline_panel::ScrollCursorCenter",
+      "z c": "outline_panel::ScrollCursorCenter",
       "z b": "outline_panel::ScrollCursorBottom",
       "0": ["vim::Number", 0],
       "1": ["vim::Number", 1],

From b30ef390e13f141f8f2c5135c721c7248ef44bd5 Mon Sep 17 00:00:00 2001
From: sunwukk990 <82385875+grgwuk990@users.noreply.github.com>
Date: Tue, 30 Jun 2026 12:54:26 -0400
Subject: [PATCH 005/422] editor: Use visible cursor position for rename
 (#55542)

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 #54160

Summary:

In Helix mode, rename could fail when the cursor was visually positioned
on the last character of a symbol. The editor renders some Vim/Helix
selections with the visible cursor offset from the selection head, but
rename was still using the raw selection head as the LSP prepare-rename
position.

This updates rename to use the visible cursor position for point lookup
when cursor offset rendering is active. It also adds regression coverage
for Helix rename and Vim visual rename.

Verification:

- `cargo check -p editor`
- `cargo fmt --check --package editor --package vim`
- `git diff --check -- crates\editor\src\editor.rs
crates\vim\src\test.rs`
- `test::test_rename`
- `test::test_helix_rename_uses_visible_cursor_position`
- `test::test_visual_rename_uses_visible_cursor_position`

Release Notes:

- Fixed Helix mode rename when the cursor is visually on the last
character of a symbol.

---------

Co-authored-by: dino 
---
 crates/editor/src/editor.rs | 38 ++++++++++++++++++++++++----
 crates/vim/src/helix.rs     | 50 +++++++++++++++++++++++++++++++++++++
 crates/vim/src/test.rs      | 43 +++++++++++++++++++++++++++++++
 3 files changed, 126 insertions(+), 5 deletions(-)

diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs
index 1be65d476b8f65..9437b8899b7a85 100644
--- a/crates/editor/src/editor.rs
+++ b/crates/editor/src/editor.rs
@@ -3039,6 +3039,35 @@ impl Editor {
         self.cursor_offset_on_selection = set_cursor_offset_on_selection;
     }
 
+    /// Returns the anchor to use as the rename target for a selection.
+    ///
+    /// In selection-based modes, like vim's visual mode and helix, the rendered
+    /// block cursor sits one position to the left of the selection's head,
+    /// since the head is the exclusive end of a forward selection. Using the
+    /// head
+    /// directly would place the rename one character past the symbol, for
+    /// example, trailing whitespace, so for a non-empty forward selection we
+    /// shift one point left to land back on the symbol under the cursor.
+    fn rename_target_anchor(&self, selection: &Selection, cx: &mut App) -> Anchor {
+        let head = selection.head();
+
+        if self.cursor_offset_on_selection
+            && !selection.reversed
+            && selection.start != selection.end
+        {
+            let display_map = self.display_snapshot(cx);
+            let display_head = head.to_display_point(&display_map);
+
+            if display_head.column() > 0 {
+                return display_map.display_point_to_anchor(
+                    movement::left(&display_map, display_head),
+                    Bias::Left,
+                );
+            }
+        }
+        head
+    }
+
     pub fn set_current_line_highlight(
         &mut self,
         current_line_highlight: Option,
@@ -7632,10 +7661,9 @@ impl Editor {
         }
         let provider = self.semantics_provider.clone()?;
         let selection = self.selections.newest_anchor().clone();
-        let (cursor_buffer, cursor_buffer_position) = self
-            .buffer
-            .read(cx)
-            .text_anchor_for_position(selection.head(), cx)?;
+        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 (tail_buffer, cursor_buffer_position_end) = self
             .buffer
             .read(cx)
@@ -7663,7 +7691,7 @@ impl Editor {
 
                     this.take_rename(false, window, cx);
                     let buffer = this.buffer.read(cx).read(cx);
-                    let cursor_offset = selection.head().to_offset(&buffer);
+                    let cursor_offset = cursor.to_offset(&buffer);
                     let rename_start =
                         cursor_offset.saturating_sub_usize(cursor_offset_in_rename_range);
                     let rename_end = rename_start + rename_buffer_range.len();
diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs
index 092db5fe7542c7..2642221b31cd80 100644
--- a/crates/vim/src/helix.rs
+++ b/crates/vim/src/helix.rs
@@ -1759,6 +1759,7 @@ struct HelixJumpUiData {
 
 #[cfg(test)]
 mod test {
+    use futures::StreamExt;
     use std::{fmt::Write, time::Duration};
 
     use editor::{HighlightKey, MultiBufferOffset};
@@ -4304,4 +4305,53 @@ mod test {
         );
         assert_eq!(cx.active_operator(), None);
     }
+
+    #[gpui::test]
+    async fn test_helix_rename_uses_visible_cursor_position(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new_typescript(cx).await;
+        cx.enable_helix();
+
+        cx.set_state(
+            "const before = 2; console.log(«beforeˇ»)",
+            Mode::HelixNormal,
+        );
+
+        let expected_position = cx.to_lsp(MultiBufferOffset(
+            "const before = 2; console.log(befor".len(),
+        ));
+        let def_range = cx.lsp_range("const «beforeˇ» = 2; console.log(before)");
+        let tgt_range = cx.lsp_range("const before = 2; console.log(«beforeˇ»)");
+        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)))
+                },
+            );
+        let mut rename_request = cx.set_request_handler::(
+            move |url, params, _| async move {
+                Ok(Some(lsp::WorkspaceEdit {
+                    changes: Some(
+                        [(
+                            url.clone(),
+                            vec![
+                                lsp::TextEdit::new(def_range, params.new_name.clone()),
+                                lsp::TextEdit::new(tgt_range, params.new_name),
+                            ],
+                        )]
+                        .into(),
+                    ),
+                    ..Default::default()
+                }))
+            },
+        );
+
+        cx.simulate_keystrokes("space r");
+        prepare_request.next().await.unwrap();
+        cx.simulate_input("after");
+        cx.simulate_keystrokes("enter");
+        rename_request.next().await.unwrap();
+
+        cx.assert_state("const after = 2; console.log(afterˇ)", Mode::HelixNormal);
+    }
 }
diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs
index fed30f8dea8090..c426127dc075d7 100644
--- a/crates/vim/src/test.rs
+++ b/crates/vim/src/test.rs
@@ -1258,6 +1258,49 @@ async fn test_rename(cx: &mut gpui::TestAppContext) {
     cx.assert_state("const afterˇ = 2; console.log(after)", Mode::Normal)
 }
 
+#[gpui::test]
+async fn test_visual_rename_uses_visible_cursor_position(cx: &mut gpui::TestAppContext) {
+    let mut cx = VimTestContext::new_typescript(cx).await;
+
+    cx.set_state("const before = 2; console.log(«beforeˇ»)", Mode::Visual);
+
+    let expected_position = cx.to_lsp(MultiBufferOffset(
+        "const before = 2; console.log(befor".len(),
+    ));
+    let def_range = cx.lsp_range("const «beforeˇ» = 2; console.log(before)");
+    let tgt_range = cx.lsp_range("const before = 2; console.log(«beforeˇ»)");
+    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)))
+        },
+    );
+    let mut rename_request =
+        cx.set_request_handler::(move |url, params, _| async move {
+            Ok(Some(lsp::WorkspaceEdit {
+                changes: Some(
+                    [(
+                        url.clone(),
+                        vec![
+                            lsp::TextEdit::new(def_range, params.new_name.clone()),
+                            lsp::TextEdit::new(tgt_range, params.new_name),
+                        ],
+                    )]
+                    .into(),
+                ),
+                ..Default::default()
+            }))
+        });
+
+    cx.simulate_keystrokes("g r n");
+    prepare_request.next().await.unwrap();
+    cx.simulate_input("after");
+    cx.simulate_keystrokes("enter");
+    rename_request.next().await.unwrap();
+
+    cx.assert_state("const after = 2; console.log(afterˇ)", Mode::Visual);
+}
+
 #[gpui::test]
 async fn test_go_to_definition(cx: &mut gpui::TestAppContext) {
     let mut cx = VimTestContext::new_typescript(cx).await;

From df33d7866100abb8b8a21bd61507402eb4c81b90 Mon Sep 17 00:00:00 2001
From: Bennet Bo Fenner 
Date: Tue, 30 Jun 2026 22:10:06 +0200
Subject: [PATCH 006/422] cli: Always open new window when
 `cli_default_open_behavior=new_window` (#59415)

This makes sure that the behaviour of the CLI matches that of the UI.
In Zed itself we always open a new window (e.g. when clicking on Open
Project in new window in the recent projects picker), but in the CLI we
where re-using existing windows even when specifying
"cli_default_open_behavior": "new_window".
This caused confusion, so this PR ensures that we always open a new
window, even if that folder is already open. We still focus the active
window in the case where the path is a subpath of a project that is
already open.

Can be tested with

```
cd crates/cli
cargo run -- --zed ../../target/debug/zed somefolder
```

Release Notes:

- cli: Ensure `zed somefolder` always opens a new window, even when
`cli_default_open_behavior` is set to `new_window` and the project is
already open
---
 assets/settings/default.json             |   3 +-
 crates/cli/src/cli.rs                    |   9 +-
 crates/project/src/project.rs            |  24 ++++
 crates/settings_content/src/workspace.rs |   3 +-
 crates/workspace/src/workspace.rs        |  25 +++-
 crates/zed/src/zed/open_listener.rs      | 169 +++++++++++++++++++++--
 6 files changed, 207 insertions(+), 26 deletions(-)

diff --git a/assets/settings/default.json b/assets/settings/default.json
index c1ca00ee0ac88d..74560dd49bb025 100644
--- a/assets/settings/default.json
+++ b/assets/settings/default.json
@@ -162,8 +162,7 @@
   // May take 2 values:
   //  1. Open directories as a new workspace in the current Zed window's sidebar
   //         "cli_default_open_behavior": "existing_window"
-  //  2. Open directories in a new window (reuse existing windows for files
-  //     that are already part of an open project)
+  //  2. Open paths in a new window, unless they are subpaths of an existing project
   //         "cli_default_open_behavior": "new_window"
   "cli_default_open_behavior": "existing_window",
   // The default behavior when opening projects from the UI.
diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs
index 0823f1fafee092..d7ad0b347ed3f4 100644
--- a/crates/cli/src/cli.rs
+++ b/crates/cli/src/cli.rs
@@ -16,13 +16,14 @@ pub struct IpcHandshake {
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
 #[serde(rename_all = "snake_case")]
 pub enum OpenBehavior {
-    /// Consult the user's `cli_default_open_behavior` setting to choose between
-    /// `ExistingWindow` or `Classic`.
+    /// Consult the user's `cli_default_open_behavior` setting.
     #[default]
     Default,
     /// Always create a new window. No matching against existing worktrees.
     /// Corresponds to `zed -n`.
     AlwaysNew,
+    /// Create a new window unless opening a subpath of an existing project.
+    PreferNewWindow,
     /// Match broadly including subdirectories, and fall back to any existing
     /// window if no worktree matched. Corresponds to `zed -a`.
     Add,
@@ -47,9 +48,7 @@ pub enum OpenBehavior {
 pub enum CliBehaviorSetting {
     /// Open directories as a new workspace in the current Zed window's sidebar.
     ExistingWindow,
-    /// Classic behavior: open directories in a new window, but reuse an
-    /// existing window when opening files that are already part of an open
-    /// project.
+    /// Open paths in a new window unless they are subpaths of an existing project.
     NewWindow,
 }
 
diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs
index 2864313d8d719b..90b4a6a5997150 100644
--- a/crates/project/src/project.rs
+++ b/crates/project/src/project.rs
@@ -2535,6 +2535,30 @@ impl Project {
             .max()
     }
 
+    pub fn visibility_for_subpaths(&self, paths: &[PathBuf], cx: &App) -> Option {
+        paths
+            .iter()
+            .map(|path| self.visibility_for_subpath(path, cx))
+            .max()
+            .flatten()
+    }
+
+    fn visibility_for_subpath(&self, path: &Path, cx: &App) -> Option {
+        let path = SanitizedPath::new(path).as_path();
+        let path_style = self.path_style(cx);
+        self.worktrees(cx)
+            .filter_map(|worktree| {
+                let worktree = worktree.read(cx);
+                let abs_path = worktree.abs_path();
+                let relative_path = path_style.strip_prefix(path, abs_path.as_ref());
+                let is_subpath = relative_path
+                    .as_ref()
+                    .is_some_and(|p| !p.as_ref().as_unix_str().is_empty());
+                is_subpath.then(|| worktree.is_visible())
+            })
+            .max()
+    }
+
     pub fn create_entry(
         &mut self,
         project_path: impl Into,
diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs
index 780fc1dc5428c1..c52cda911d4332 100644
--- a/crates/settings_content/src/workspace.rs
+++ b/crates/settings_content/src/workspace.rs
@@ -408,8 +408,7 @@ pub enum CliDefaultOpenBehavior {
     #[default]
     #[strum(serialize = "Add to Existing Window")]
     ExistingWindow,
-    /// Open directories in a new window, but reuse an existing window when
-    /// opening files that are already part of an open project.
+    /// Open paths in a new window unless they are subpaths of an existing project.
     #[strum(serialize = "Open a New Window")]
     NewWindow,
 }
diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs
index cef5ece31b0171..161f65974c4980 100644
--- a/crates/workspace/src/workspace.rs
+++ b/crates/workspace/src/workspace.rs
@@ -9794,11 +9794,18 @@ pub async fn find_existing_workspace(
                 if let Ok(multi_workspace) = window.read(cx) {
                     for workspace in multi_workspace.workspaces() {
                         let project = workspace.read(cx).project.read(cx);
-                        let m = project.visibility_for_paths(
-                            abs_paths,
-                            open_options.workspace_matching != WorkspaceMatching::MatchSubdirectory,
-                            cx,
-                        );
+                        let m = match open_options.workspace_matching {
+                            WorkspaceMatching::None => None,
+                            WorkspaceMatching::MatchExact => {
+                                project.visibility_for_paths(abs_paths, true, cx)
+                            }
+                            WorkspaceMatching::MatchSubpaths => {
+                                project.visibility_for_subpaths(abs_paths, cx)
+                            }
+                            WorkspaceMatching::MatchSubdirectory => {
+                                project.visibility_for_paths(abs_paths, false, cx)
+                            }
+                        };
                         if m > best_match {
                             existing = Some((window, workspace.clone()));
                             best_match = m;
@@ -9865,6 +9872,9 @@ pub enum WorkspaceMatching {
     /// Match paths against existing worktree roots and files within them.
     #[default]
     MatchExact,
+    /// Match files and directories inside existing worktrees, excluding the
+    /// worktree roots themselves.
+    MatchSubpaths,
     /// Match paths against existing worktrees including subdirectories, and
     /// fall back to any existing window if no worktree matched.
     ///
@@ -9907,7 +9917,10 @@ impl Default for OpenOptions {
 
 impl OpenOptions {
     fn should_reuse_existing_window(&self) -> bool {
-        self.workspace_matching != WorkspaceMatching::None && self.open_mode != OpenMode::NewWindow
+        !matches!(
+            self.workspace_matching,
+            WorkspaceMatching::None | WorkspaceMatching::MatchSubpaths
+        ) && self.open_mode != OpenMode::NewWindow
     }
 }
 
diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs
index 80a851571b8379..a992dfe4fd986c 100644
--- a/crates/zed/src/zed/open_listener.rs
+++ b/crates/zed/src/zed/open_listener.rs
@@ -612,12 +612,16 @@ pub async fn handle_cli_connection(
                             open_behavior = cli::OpenBehavior::ExistingWindow;
                         }
                         Some(settings::CliDefaultOpenBehavior::NewWindow) => {
-                            open_behavior = cli::OpenBehavior::Classic;
+                            open_behavior = cli::OpenBehavior::PreferNewWindow;
                         }
                         None => {}
                     }
                 }
 
+                if open_behavior == cli::OpenBehavior::Default {
+                    open_behavior = cx.update(|cx| open_behavior_for_default_setting(cx));
+                }
+
                 cx.update(|cx| cx.activate(true));
 
                 let open_workspace_result = open_workspaces(
@@ -647,8 +651,8 @@ pub async fn handle_cli_connection(
     }
 }
 
-/// Resolves the CLI open behavior when no explicit flag (`-n`, `-e`, `--reuse`)
-/// was given. May prompt the user interactively on first run.
+/// Resolves the CLI open behavior when no explicit open behavior flag was given.
+/// May prompt the user interactively on first run.
 ///
 /// Returns `Some(behavior)` to override the default, or `None` if no override
 /// is needed (e.g. no existing windows, paths already in a workspace, or the
@@ -756,6 +760,12 @@ pub(crate) fn open_options_for_behavior(
     location: &SerializedWorkspaceLocation,
     cx: &App,
 ) -> workspace::OpenOptions {
+    let open_behavior = if open_behavior == cli::OpenBehavior::Default {
+        open_behavior_for_default_setting(cx)
+    } else {
+        open_behavior
+    };
+
     // If reuse flag is passed, open a new workspace in an existing window.
     let requesting_window = if open_behavior == cli::OpenBehavior::Reuse {
         workspace::workspace_windows_for_location(location, cx)
@@ -769,17 +779,12 @@ pub(crate) fn open_options_for_behavior(
             cli::OpenBehavior::AlwaysNew | cli::OpenBehavior::Reuse => {
                 workspace::WorkspaceMatching::None
             }
+            cli::OpenBehavior::PreferNewWindow => workspace::WorkspaceMatching::MatchSubpaths,
             cli::OpenBehavior::Add => workspace::WorkspaceMatching::MatchSubdirectory,
             _ => workspace::WorkspaceMatching::MatchExact,
         },
         add_dirs_to_sidebar: match open_behavior {
             cli::OpenBehavior::ExistingWindow => true,
-            // For the default value, we consult the settings to decide
-            // whether to open in a new window or existing window.
-            cli::OpenBehavior::Default => {
-                workspace::WorkspaceSettings::get_global(cx).cli_default_open_behavior
-                    == settings::CliDefaultOpenBehavior::ExistingWindow
-            }
             _ => false,
         },
         requesting_window,
@@ -787,6 +792,13 @@ pub(crate) fn open_options_for_behavior(
     }
 }
 
+fn open_behavior_for_default_setting(cx: &App) -> cli::OpenBehavior {
+    match workspace::WorkspaceSettings::get_global(cx).cli_default_open_behavior {
+        settings::CliDefaultOpenBehavior::ExistingWindow => cli::OpenBehavior::ExistingWindow,
+        settings::CliDefaultOpenBehavior::NewWindow => cli::OpenBehavior::PreferNewWindow,
+    }
+}
+
 async fn open_workspaces(
     paths: Vec,
     diff_paths: Vec<[String; 2]>,
@@ -800,7 +812,13 @@ async fn open_workspaces(
     cwd: Option,
     cx: &mut AsyncApp,
 ) -> Result<()> {
-    if paths.is_empty() && diff_paths.is_empty() && open_behavior != cli::OpenBehavior::AlwaysNew {
+    if paths.is_empty()
+        && diff_paths.is_empty()
+        && !matches!(
+            open_behavior,
+            cli::OpenBehavior::AlwaysNew | cli::OpenBehavior::PreferNewWindow
+        )
+    {
         return restore_or_create_workspace(app_state, cx).await;
     }
 
@@ -1082,7 +1100,7 @@ mod tests {
     use cli::CliResponse;
     use editor::Editor;
     use futures::poll;
-    use gpui::{AppContext as _, TestAppContext};
+    use gpui::{AppContext as _, TestAppContext, UpdateGlobal as _};
     use language::LineEnding;
     use remote::SshConnectionOptions;
     use rope::Rope;
@@ -2589,6 +2607,135 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_e2e_new_window_setting_opens_project_root_in_new_window(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(path!("/project"), json!({ "file.txt": "content" }))
+            .await;
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                paths::config_dir(),
+                json!({
+                    "settings.json": r#"{"cli_default_open_behavior": "new_window"}"#
+                }),
+            )
+            .await;
+
+        cx.update(|cx| {
+            settings::SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings(cx, |settings| {
+                    settings.workspace.cli_default_open_behavior =
+                        Some(settings::CliDefaultOpenBehavior::NewWindow);
+                });
+            });
+        });
+
+        open_workspace_file(path!("/project"), Default::default(), app_state.clone(), cx).await;
+        assert_eq!(cx.windows().len(), 1);
+
+        let (status, prompt_shown) = run_cli_with_zed_handler(
+            cx,
+            app_state,
+            make_cli_open_request(
+                vec![path!("/project").to_string()],
+                cli::OpenBehavior::Default,
+            ),
+            None,
+        );
+
+        assert_eq!(status, 0);
+        assert!(
+            !prompt_shown,
+            "no prompt should be shown when setting already configured"
+        );
+        assert_eq!(cx.windows().len(), 2);
+    }
+
+    #[gpui::test]
+    async fn test_e2e_new_window_setting_focuses_existing_window_for_subpaths(
+        cx: &mut TestAppContext,
+    ) {
+        let app_state = init_test(cx);
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                path!("/project"),
+                json!({
+                    "file.txt": "content",
+                    "src": {
+                        "main.rs": "fn main() {}",
+                    },
+                }),
+            )
+            .await;
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                paths::config_dir(),
+                json!({
+                    "settings.json": r#"{"cli_default_open_behavior": "new_window"}"#
+                }),
+            )
+            .await;
+
+        cx.update(|cx| {
+            settings::SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings(cx, |settings| {
+                    settings.workspace.cli_default_open_behavior =
+                        Some(settings::CliDefaultOpenBehavior::NewWindow);
+                });
+            });
+        });
+
+        open_workspace_file(path!("/project"), Default::default(), app_state.clone(), cx).await;
+        assert_eq!(cx.windows().len(), 1);
+
+        let (status, prompt_shown) = run_cli_with_zed_handler(
+            cx,
+            app_state.clone(),
+            make_cli_open_request(
+                vec![path!("/project/src").to_string()],
+                cli::OpenBehavior::Default,
+            ),
+            None,
+        );
+
+        assert_eq!(status, 0);
+        assert!(
+            !prompt_shown,
+            "no prompt should be shown when setting already configured"
+        );
+        assert_eq!(cx.windows().len(), 1);
+
+        let (status, prompt_shown) = run_cli_with_zed_handler(
+            cx,
+            app_state,
+            make_cli_open_request(
+                vec![path!("/project/file.txt").to_string()],
+                cli::OpenBehavior::Default,
+            ),
+            None,
+        );
+
+        assert_eq!(status, 0);
+        assert!(
+            !prompt_shown,
+            "no prompt should be shown when setting already configured"
+        );
+        assert_eq!(cx.windows().len(), 1);
+    }
+
     #[gpui::test]
     async fn test_e2e_explicit_existing_flag_no_prompt(cx: &mut TestAppContext) {
         let app_state = init_test(cx);

From 40d20036af34343a09f0ce6a2eb38c9e5a60e9ae Mon Sep 17 00:00:00 2001
From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
Date: Wed, 1 Jul 2026 01:25:40 -0300
Subject: [PATCH 007/422] settings_ui: Remove feature flag for AI settings and
 adjust design (#59860)

Closes AI-159
Closes AI-434
Closes AI-435

Release Notes:

- Key agent-related settings now live in the settings editor, close to
all other settings available in Zed. This specifically includes the move
of LLM providers, external agents, and MCP servers to the settings
editor.

---------

Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
Co-authored-by: Bennet Bo Fenner 
---
 assets/keymaps/default-linux.json             |    7 -
 assets/keymaps/default-macos.json             |   13 -
 assets/keymaps/default-windows.json           |    7 -
 crates/agent_ui/src/agent_configuration.rs    | 1679 -----------------
 .../add_llm_provider_modal.rs                 | 1201 ------------
 .../configure_context_server_modal.rs         |    8 +-
 .../configure_context_server_tools_modal.rs   |  175 --
 crates/agent_ui/src/agent_panel.rs            |  360 +---
 crates/copilot_ui/src/sign_in.rs              |   92 +-
 crates/feature_flags/src/flags.rs             |   14 +-
 crates/language_model/src/language_model.rs   |   42 +-
 crates/language_models/src/api_key_editor.rs  |  155 --
 crates/language_models/src/language_models.rs |    2 -
 .../language_models/src/provider/anthropic.rs |   49 +-
 .../language_models/src/provider/bedrock.rs   |  122 +-
 crates/language_models/src/provider/cloud.rs  |  203 +-
 .../src/provider/copilot_chat.rs              |   45 +-
 .../language_models/src/provider/deepseek.rs  |   49 +-
 crates/language_models/src/provider/google.rs |   43 +-
 .../language_models/src/provider/llama_cpp.rs |  145 +-
 .../language_models/src/provider/lmstudio.rs  |  110 +-
 .../language_models/src/provider/mistral.rs   |   49 +-
 crates/language_models/src/provider/ollama.rs |  136 +-
 .../language_models/src/provider/open_ai.rs   |   47 +-
 .../src/provider/open_router.rs               |   49 +-
 .../src/provider/openai_subscribed.rs         |   74 +-
 .../language_models/src/provider/opencode.rs  |  107 +-
 .../src/provider/vercel_ai_gateway.rs         |   49 +-
 crates/language_models/src/provider/x_ai.rs   |   47 +-
 crates/settings_ui/src/page_data.rs           |  162 +-
 crates/settings_ui/src/pages.rs               |    8 +-
 .../pages/edit_prediction_provider_setup.rs   |    9 +-
 .../src/pages/external_agents_page.rs         |   83 +-
 .../src/pages/llm_providers_page.rs           |  302 ++-
 .../settings_ui/src/pages/mcp_servers_page.rs |  193 +-
 .../settings_ui/src/pages/sandbox_settings.rs |   24 +-
 crates/settings_ui/src/settings_ui.rs         |  106 +-
 .../ui/src/components/ai/ai_setting_item.rs   |    7 +-
 .../src/components/ai/configured_api_card.rs  |  106 +-
 crates/ui_input/src/input_field.rs            |    6 +-
 crates/zed_actions/src/lib.rs                 |   12 +
 docs/src/ai/agent-settings.md                 |   33 +-
 docs/src/ai/external-agents.md                |    4 +-
 docs/src/ai/mcp.md                            |    6 +-
 docs/src/ai/use-a-gateway.md                  |    4 +-
 docs/src/ai/use-api-access.md                 |    2 +-
 46 files changed, 1487 insertions(+), 4659 deletions(-)
 delete mode 100644 crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs
 delete mode 100644 crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs
 delete mode 100644 crates/language_models/src/api_key_editor.rs

diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json
index 56b853384f9ac1..90a440a6447af4 100644
--- a/assets/keymaps/default-linux.json
+++ b/assets/keymaps/default-linux.json
@@ -1281,13 +1281,6 @@
       "ctrl-enter": "menu::Confirm",
     },
   },
-  {
-    "context": "ContextServerToolsModal",
-    "use_key_equivalents": true,
-    "bindings": {
-      "escape": "menu::Cancel",
-    },
-  },
   {
     "context": "OnboardingAiConfigurationModal",
     "use_key_equivalents": true,
diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json
index 009490e42781c0..8b4e76dd81f01b 100644
--- a/assets/keymaps/default-macos.json
+++ b/assets/keymaps/default-macos.json
@@ -288,12 +288,6 @@
       "alt-enter": "editor::Newline",
     },
   },
-  {
-    "context": "AgentConfiguration",
-    "bindings": {
-      "ctrl--": "pane::GoBack",
-    },
-  },
   {
     "context": "AcpThread > ModeSelector",
     "bindings": {
@@ -1379,13 +1373,6 @@
       "cmd-enter": "menu::Confirm",
     },
   },
-  {
-    "context": "ContextServerToolsModal",
-    "use_key_equivalents": true,
-    "bindings": {
-      "escape": "menu::Cancel",
-    },
-  },
   {
     "context": "OnboardingAiConfigurationModal",
     "use_key_equivalents": true,
diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json
index 035e470445824b..08a9274c3170ca 100644
--- a/assets/keymaps/default-windows.json
+++ b/assets/keymaps/default-windows.json
@@ -1303,13 +1303,6 @@
       "ctrl-enter": "menu::Confirm",
     },
   },
-  {
-    "context": "ContextServerToolsModal",
-    "use_key_equivalents": true,
-    "bindings": {
-      "escape": "menu::Cancel",
-    },
-  },
   {
     "context": "OnboardingAiConfigurationModal",
     "use_key_equivalents": true,
diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs
index 6164fcf72bc4d7..ec700744438c0f 100644
--- a/crates/agent_ui/src/agent_configuration.rs
+++ b/crates/agent_ui/src/agent_configuration.rs
@@ -1,1685 +1,6 @@
-mod add_llm_provider_modal;
 pub mod configure_context_server_modal;
-mod configure_context_server_tools_modal;
 mod manage_profiles_modal;
 mod tool_picker;
 
-use std::{ops::Range, rc::Rc, sync::Arc};
-
-use agent::ContextServerRegistry;
-use anyhow::Result;
-use cloud_api_types::Plan;
-use collections::HashMap;
-use context_server::ContextServerId;
-use editor::{Editor, MultiBufferOffset, SelectionEffects, scroll::Autoscroll};
-use extension::ExtensionManifest;
-use extension_host::ExtensionStore;
-use fs::Fs;
-use gpui::{
-    Action, Anchor, AnyView, App, AsyncWindowContext, Entity, EventEmitter, FocusHandle, Focusable,
-    ScrollHandle, Subscription, Task, TaskExt, WeakEntity,
-};
-use itertools::Itertools;
-use language::LanguageRegistry;
-use language_model::{
-    IconOrSvg, LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry,
-    ZED_CLOUD_PROVIDER_ID,
-};
-use language_models::AllLanguageModelSettings;
-use notifications::status_toast::StatusToast;
-use project::{
-    agent_server_store::{AgentId, AgentServerStore, ExternalAgentSource},
-    context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore},
-};
-use settings::{Settings, SettingsContent, SettingsStore, update_settings_file};
-use ui::{
-    AiSettingItem, AiSettingItemSource, AiSettingItemStatus, ButtonStyle, Chip, ContextMenu,
-    ContextMenuEntry, Disclosure, Divider, DividerColor, ElevationIndex, LabelSize, PopoverMenu,
-    Switch, Tooltip, WithScrollbar, prelude::*,
-};
-use util::ResultExt as _;
-use workspace::{Workspace, create_and_open_local_file};
-use zed_actions::{ExtensionCategoryFilter, OpenBrowser};
-
 pub(crate) use configure_context_server_modal::ConfigureContextServerModal;
-pub(crate) use configure_context_server_tools_modal::ConfigureContextServerToolsModal;
 pub(crate) use manage_profiles_modal::ManageProfilesModal;
-
-use crate::{
-    Agent,
-    agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider},
-    agent_connection_store::{AgentConnectionStatus, AgentConnectionStore},
-};
-
-pub struct AgentConfiguration {
-    fs: Arc,
-    language_registry: Arc,
-    agent_server_store: Entity,
-    agent_connection_store: Entity,
-    workspace: WeakEntity,
-    focus_handle: FocusHandle,
-    configuration_views_by_provider: HashMap,
-    context_server_store: Entity,
-    expanded_provider_configurations: HashMap,
-    context_server_registry: Entity,
-    _subscriptions: Vec,
-    scroll_handle: ScrollHandle,
-}
-
-impl AgentConfiguration {
-    pub fn new(
-        fs: Arc,
-        agent_server_store: Entity,
-        agent_connection_store: Entity,
-        context_server_store: Entity,
-        context_server_registry: Entity,
-        language_registry: Arc,
-        workspace: WeakEntity,
-        window: &mut Window,
-        cx: &mut Context,
-    ) -> Self {
-        let focus_handle = cx.focus_handle();
-
-        let subscriptions = vec![
-            cx.subscribe_in(
-                &LanguageModelRegistry::global(cx),
-                window,
-                |this, _, event: &language_model::Event, window, cx| match event {
-                    language_model::Event::AddedProvider(provider_id) => {
-                        let provider = LanguageModelRegistry::read_global(cx).provider(provider_id);
-                        if let Some(provider) = provider {
-                            this.add_provider_configuration_view(&provider, window, cx);
-                        }
-                    }
-                    language_model::Event::RemovedProvider(provider_id) => {
-                        this.remove_provider_configuration_view(provider_id);
-                    }
-                    _ => {}
-                },
-            ),
-            cx.subscribe(&agent_server_store, |_, _, _, cx| cx.notify()),
-            cx.observe(&agent_connection_store, |_, _, cx| cx.notify()),
-            cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify()),
-        ];
-
-        let mut this = Self {
-            fs,
-            language_registry,
-            workspace,
-            focus_handle,
-            configuration_views_by_provider: HashMap::default(),
-            agent_server_store,
-            agent_connection_store,
-            context_server_store,
-            expanded_provider_configurations: HashMap::default(),
-            context_server_registry,
-            _subscriptions: subscriptions,
-            scroll_handle: ScrollHandle::new(),
-        };
-
-        this.build_provider_configuration_views(window, cx);
-        this
-    }
-
-    fn build_provider_configuration_views(&mut self, window: &mut Window, cx: &mut Context) {
-        let providers = LanguageModelRegistry::read_global(cx).visible_providers();
-        for provider in providers {
-            self.add_provider_configuration_view(&provider, window, cx);
-        }
-    }
-
-    fn remove_provider_configuration_view(&mut self, provider_id: &LanguageModelProviderId) {
-        self.configuration_views_by_provider.remove(provider_id);
-        self.expanded_provider_configurations.remove(provider_id);
-    }
-
-    fn add_provider_configuration_view(
-        &mut self,
-        provider: &Arc,
-        window: &mut Window,
-        cx: &mut Context,
-    ) {
-        let configuration_view = provider.configuration_view(
-            language_model::ConfigurationViewTargetAgent::ZedAgent,
-            window,
-            cx,
-        );
-        self.configuration_views_by_provider
-            .insert(provider.id(), configuration_view);
-    }
-}
-
-impl Focusable for AgentConfiguration {
-    fn focus_handle(&self, _: &App) -> FocusHandle {
-        self.focus_handle.clone()
-    }
-}
-
-pub enum AssistantConfigurationEvent {
-    NewThread(Arc),
-}
-
-impl EventEmitter for AgentConfiguration {}
-
-enum AgentIcon {
-    Name(IconName),
-    Path(SharedString),
-}
-
-impl AgentConfiguration {
-    fn render_section_title(
-        &mut self,
-        title: impl Into,
-        description: impl Into,
-        menu: AnyElement,
-    ) -> impl IntoElement {
-        h_flex()
-            .p_4()
-            .pb_0()
-            .mb_2p5()
-            .items_start()
-            .justify_between()
-            .child(
-                v_flex()
-                    .w_full()
-                    .gap_0p5()
-                    .child(
-                        h_flex()
-                            .pr_1()
-                            .w_full()
-                            .gap_2()
-                            .justify_between()
-                            .flex_wrap()
-                            .child(Headline::new(title.into()))
-                            .child(menu),
-                    )
-                    .child(Label::new(description.into()).color(Color::Muted)),
-            )
-    }
-
-    fn render_provider_configuration_block(
-        &mut self,
-        provider: &Arc,
-        cx: &mut Context,
-    ) -> impl IntoElement + use<> {
-        let provider_id = provider.id().0;
-        let provider_name = provider.name().0;
-        let provider_id_string = SharedString::from(format!("provider-disclosure-{provider_id}"));
-
-        let configuration_view = self
-            .configuration_views_by_provider
-            .get(&provider.id())
-            .cloned();
-
-        let is_expanded = self
-            .expanded_provider_configurations
-            .get(&provider.id())
-            .copied()
-            .unwrap_or(false);
-
-        let is_zed_provider = provider.id() == ZED_CLOUD_PROVIDER_ID;
-        let current_plan = if is_zed_provider {
-            self.workspace
-                .upgrade()
-                .and_then(|workspace| workspace.read(cx).user_store().read(cx).plan())
-        } else {
-            None
-        };
-
-        let is_signed_in = self
-            .workspace
-            .read_with(cx, |workspace, _| {
-                !workspace.client().status().borrow().is_signed_out()
-            })
-            .unwrap_or(false);
-
-        v_flex()
-            .min_w_0()
-            .w_full()
-            .when(is_expanded, |this| this.mb_2())
-            .child(
-                div()
-                    .px_2()
-                    .child(Divider::horizontal().color(DividerColor::BorderFaded)),
-            )
-            .child(
-                h_flex()
-                    .map(|this| {
-                        if is_expanded {
-                            this.mt_2().mb_1()
-                        } else {
-                            this.my_2()
-                        }
-                    })
-                    .w_full()
-                    .justify_between()
-                    .child(
-                        h_flex()
-                            .id(provider_id_string.clone())
-                            .px_2()
-                            .py_0p5()
-                            .w_full()
-                            .justify_between()
-                            .rounded_sm()
-                            .hover(|hover| hover.bg(cx.theme().colors().element_hover))
-                            .child(
-                                h_flex()
-                                    .w_full()
-                                    .gap_1p5()
-                                    .child(
-                                        match provider.icon() {
-                                            IconOrSvg::Svg(path) => Icon::from_external_svg(path),
-                                            IconOrSvg::Icon(name) => Icon::new(name),
-                                        }
-                                        .size(IconSize::Small)
-                                        .color(Color::Muted),
-                                    )
-                                    .child(
-                                        h_flex()
-                                            .w_full()
-                                            .gap_1()
-                                            .child(Label::new(provider_name.clone()))
-                                            .map(|this| {
-                                                if is_zed_provider && is_signed_in {
-                                                    this.child(
-                                                        self.render_zed_plan_info(current_plan, cx),
-                                                    )
-                                                } else {
-                                                    this.when(
-                                                        provider.is_authenticated(cx)
-                                                            && !is_expanded,
-                                                        |parent| {
-                                                            parent.child(
-                                                                Icon::new(IconName::Check)
-                                                                    .color(Color::Success),
-                                                            )
-                                                        },
-                                                    )
-                                                }
-                                            }),
-                                    ),
-                            )
-                            .child(
-                                Disclosure::new(provider_id_string, is_expanded)
-                                    .opened_icon(IconName::ChevronUp)
-                                    .closed_icon(IconName::ChevronDown),
-                            )
-                            .on_click(cx.listener({
-                                let provider_id = provider.id();
-                                move |this, _event, _window, _cx| {
-                                    let is_expanded = this
-                                        .expanded_provider_configurations
-                                        .entry(provider_id.clone())
-                                        .or_insert(false);
-
-                                    *is_expanded = !*is_expanded;
-                                }
-                            })),
-                    ),
-            )
-            .child(
-                v_flex()
-                    .min_w_0()
-                    .w_full()
-                    .px_2()
-                    .gap_1()
-                    .when(is_expanded, |parent| match configuration_view {
-                        Some(configuration_view) => parent.child(configuration_view),
-                        None => parent.child(Label::new(format!(
-                            "No configuration view for {provider_name}",
-                        ))),
-                    })
-                    .when(is_expanded && provider.is_authenticated(cx), |parent| {
-                        parent.child(
-                            Button::new(
-                                SharedString::from(format!("new-thread-{provider_id}")),
-                                "Start New Thread",
-                            )
-                            .full_width()
-                            .style(ButtonStyle::Outlined)
-                            .layer(ElevationIndex::ModalSurface)
-                            .start_icon(
-                                Icon::new(IconName::Thread)
-                                    .size(IconSize::Small)
-                                    .color(Color::Muted),
-                            )
-                            .label_size(LabelSize::Small)
-                            .on_click(cx.listener({
-                                let provider = provider.clone();
-                                move |_this, _event, _window, cx| {
-                                    cx.emit(AssistantConfigurationEvent::NewThread(
-                                        provider.clone(),
-                                    ))
-                                }
-                            })),
-                        )
-                    })
-                    .when(
-                        is_expanded && is_removable_provider(&provider.id(), cx),
-                        |this| {
-                            this.child(
-                                Button::new(
-                                    SharedString::from(format!("delete-provider-{provider_id}")),
-                                    "Remove Provider",
-                                )
-                                .full_width()
-                                .style(ButtonStyle::Outlined)
-                                .start_icon(
-                                    Icon::new(IconName::Trash)
-                                        .size(IconSize::Small)
-                                        .color(Color::Muted),
-                                )
-                                .label_size(LabelSize::Small)
-                                .on_click(cx.listener({
-                                    let provider = provider.clone();
-                                    move |this, _event, window, cx| {
-                                        this.delete_provider(provider.clone(), window, cx);
-                                    }
-                                })),
-                            )
-                        },
-                    ),
-            )
-    }
-
-    fn delete_provider(
-        &mut self,
-        provider: Arc,
-        window: &mut Window,
-        cx: &mut Context,
-    ) {
-        let fs = self.fs.clone();
-        let provider_id = provider.id();
-
-        cx.spawn_in(window, async move |_, cx| {
-            cx.update(|_window, cx| {
-                update_settings_file(fs.clone(), cx, {
-                    let provider_id = provider_id.clone();
-                    move |settings, _| {
-                        remove_compatible_provider(settings, provider_id.0.as_ref());
-                    }
-                });
-            })
-            .log_err();
-
-            cx.update(|_window, cx| {
-                LanguageModelRegistry::global(cx).update(cx, {
-                    let provider_id = provider_id.clone();
-                    move |registry, cx| {
-                        registry.unregister_provider(provider_id, cx);
-                    }
-                })
-            })
-            .log_err();
-
-            anyhow::Ok(())
-        })
-        .detach_and_log_err(cx);
-    }
-
-    fn render_provider_configuration_section(
-        &mut self,
-        cx: &mut Context,
-    ) -> impl IntoElement {
-        let providers = LanguageModelRegistry::read_global(cx).visible_providers();
-
-        let popover_menu = PopoverMenu::new("add-provider-popover")
-            .trigger(
-                Button::new("add-provider", "Add Provider")
-                    .style(ButtonStyle::Outlined)
-                    .start_icon(
-                        Icon::new(IconName::Plus)
-                            .size(IconSize::Small)
-                            .color(Color::Muted),
-                    )
-                    .label_size(LabelSize::Small),
-            )
-            .menu({
-                let workspace = self.workspace.clone();
-                move |window, cx| {
-                    let open_modal = |provider: LlmCompatibleProvider| {
-                        let workspace = workspace.clone();
-                        move |window: &mut Window, cx: &mut App| {
-                            workspace
-                                .update(cx, |workspace, cx| {
-                                    AddLlmProviderModal::toggle(provider, workspace, window, cx);
-                                })
-                                .log_err();
-                        }
-                    };
-                    Some(ContextMenu::build(window, cx, |menu, _window, _cx| {
-                        menu.header("Compatible APIs")
-                            .entry("OpenAI", None, open_modal(LlmCompatibleProvider::OpenAi))
-                            .entry(
-                                "Anthropic",
-                                None,
-                                open_modal(LlmCompatibleProvider::Anthropic),
-                            )
-                    }))
-                }
-            })
-            .anchor(gpui::Anchor::TopRight)
-            .offset(gpui::Point {
-                x: px(0.0),
-                y: px(2.0),
-            });
-
-        v_flex()
-            .min_w_0()
-            .w_full()
-            .child(self.render_section_title(
-                "LLM Providers",
-                "Add at least one provider to use AI-powered features with Zed's native agent.",
-                popover_menu.into_any_element(),
-            ))
-            .child(
-                div()
-                    .w_full()
-                    .pl(DynamicSpacing::Base08.rems(cx))
-                    .pr(DynamicSpacing::Base20.rems(cx))
-                    .children(
-                        providers.into_iter().map(|provider| {
-                            self.render_provider_configuration_block(&provider, cx)
-                        }),
-                    ),
-            )
-    }
-
-    fn render_zed_plan_info(&self, plan: Option, cx: &mut Context) -> impl IntoElement {
-        if let Some(plan) = plan {
-            let free_chip_bg = cx
-                .theme()
-                .colors()
-                .editor_background
-                .opacity(0.5)
-                .blend(cx.theme().colors().text_accent.opacity(0.05));
-
-            let pro_chip_bg = cx
-                .theme()
-                .colors()
-                .editor_background
-                .opacity(0.5)
-                .blend(cx.theme().colors().text_accent.opacity(0.2));
-
-            let (plan_name, label_color, bg_color) = match plan {
-                Plan::ZedFree => ("Free", Color::Default, free_chip_bg),
-                Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg),
-                Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg),
-                Plan::ZedBusiness => ("Business", Color::Accent, pro_chip_bg),
-                Plan::ZedVip => ("VIP", Color::Accent, pro_chip_bg),
-                Plan::ZedStudent => ("Student", Color::Accent, pro_chip_bg),
-            };
-
-            Chip::new(plan_name.to_string())
-                .bg_color(bg_color)
-                .label_color(label_color)
-                .into_any_element()
-        } else {
-            div().into_any_element()
-        }
-    }
-
-    fn render_context_servers_section(&mut self, cx: &mut Context) -> impl IntoElement {
-        let context_server_ids = self.context_server_store.read(cx).server_ids();
-
-        let add_server_popover = PopoverMenu::new("add-server-popover")
-            .trigger(
-                Button::new("add-server", "Add Server")
-                    .style(ButtonStyle::Outlined)
-                    .start_icon(
-                        Icon::new(IconName::Plus)
-                            .size(IconSize::Small)
-                            .color(Color::Muted),
-                    )
-                    .label_size(LabelSize::Small),
-            )
-            .menu({
-                move |window, cx| {
-                    Some(ContextMenu::build(window, cx, |menu, _window, _cx| {
-                        menu.entry("Add Custom Server", None, {
-                            |window, cx| {
-                                window.dispatch_action(
-                                    crate::AddContextServer::local().boxed_clone(),
-                                    cx,
-                                )
-                            }
-                        })
-                        .entry("Add Remote Server", None, {
-                            |window, cx| {
-                                window.dispatch_action(
-                                    crate::AddContextServer::remote().boxed_clone(),
-                                    cx,
-                                )
-                            }
-                        })
-                        .entry("Install from Extensions", None, {
-                            |window, cx| {
-                                window.dispatch_action(
-                                    zed_actions::Extensions {
-                                        category_filter: Some(
-                                            ExtensionCategoryFilter::ContextServers,
-                                        ),
-                                        id: None,
-                                    }
-                                    .boxed_clone(),
-                                    cx,
-                                )
-                            }
-                        })
-                    }))
-                }
-            })
-            .anchor(gpui::Anchor::TopRight)
-            .offset(gpui::Point {
-                x: px(0.0),
-                y: px(2.0),
-            });
-
-        v_flex()
-            .min_w_0()
-            .border_b_1()
-            .border_color(cx.theme().colors().border)
-            .child(self.render_section_title(
-                "Model Context Protocol (MCP) Servers",
-                "All MCP servers connected directly or via a Zed extension.",
-                add_server_popover.into_any_element(),
-            ))
-            .child(
-                v_flex()
-                    .pl_4()
-                    .pb_4()
-                    .pr_5()
-                    .w_full()
-                    .gap_1()
-                    .map(|parent| {
-                        if context_server_ids.is_empty() {
-                            parent.child(
-                                h_flex()
-                                    .p_4()
-                                    .justify_center()
-                                    .border_1()
-                                    .border_dashed()
-                                    .border_color(cx.theme().colors().border.opacity(0.6))
-                                    .rounded_sm()
-                                    .child(
-                                        Label::new("No MCP servers added yet.")
-                                            .color(Color::Muted)
-                                            .size(LabelSize::Small),
-                                    ),
-                            )
-                        } else {
-                            parent.children(itertools::intersperse_with(
-                                context_server_ids.iter().cloned().map(|context_server_id| {
-                                    self.render_context_server(context_server_id, cx)
-                                        .into_any_element()
-                                }),
-                                || {
-                                    Divider::horizontal()
-                                        .color(DividerColor::BorderFaded)
-                                        .into_any_element()
-                                },
-                            ))
-                        }
-                    }),
-            )
-    }
-
-    fn render_context_server(
-        &self,
-        context_server_id: ContextServerId,
-        cx: &Context,
-    ) -> impl use<> + IntoElement {
-        let server_status = self
-            .context_server_store
-            .read(cx)
-            .status_for_server(&context_server_id)
-            .unwrap_or(ContextServerStatus::Stopped);
-        let server_configuration = self
-            .context_server_store
-            .read(cx)
-            .configuration_for_server(&context_server_id);
-
-        let is_running = matches!(server_status, ContextServerStatus::Running);
-        let item_id = SharedString::from(context_server_id.0.clone());
-        // Servers without a configuration can only be provided by extensions.
-        let provided_by_extension = server_configuration.as_ref().is_none_or(|config| {
-            matches!(
-                config.as_ref(),
-                ContextServerConfiguration::Extension { .. }
-            )
-        });
-
-        let display_name = if provided_by_extension {
-            resolve_extension_for_context_server(&context_server_id, cx)
-                .map(|(_, manifest)| {
-                    let name = manifest.name.as_str();
-                    let stripped = name
-                        .strip_suffix(" MCP Server")
-                        .or_else(|| name.strip_suffix(" MCP"))
-                        .or_else(|| name.strip_suffix(" Context Server"))
-                        .unwrap_or(name);
-                    SharedString::from(stripped.to_string())
-                })
-                .unwrap_or_else(|| item_id.clone())
-        } else {
-            item_id.clone()
-        };
-
-        let error = if let ContextServerStatus::Error(error) = server_status.clone() {
-            Some(error)
-        } else {
-            None
-        };
-        let auth_required = matches!(server_status, ContextServerStatus::AuthRequired);
-        let client_secret_required = matches!(
-            server_status,
-            ContextServerStatus::ClientSecretRequired { .. }
-        );
-        let authenticating = matches!(server_status, ContextServerStatus::Authenticating);
-        let context_server_store = self.context_server_store.clone();
-        let workspace = self.workspace.clone();
-        let language_registry = self.language_registry.clone();
-
-        let tool_count = self
-            .context_server_registry
-            .read(cx)
-            .tools_for_server(&context_server_id)
-            .count();
-
-        let source = if provided_by_extension {
-            AiSettingItemSource::Extension
-        } else {
-            AiSettingItemSource::Custom
-        };
-
-        let status = match server_status {
-            ContextServerStatus::Starting => AiSettingItemStatus::Starting,
-            ContextServerStatus::Running => AiSettingItemStatus::Running,
-            ContextServerStatus::Error(_) => AiSettingItemStatus::Error,
-            ContextServerStatus::Stopped => AiSettingItemStatus::Stopped,
-            ContextServerStatus::AuthRequired => AiSettingItemStatus::AuthRequired,
-            ContextServerStatus::ClientSecretRequired { .. } => {
-                AiSettingItemStatus::ClientSecretRequired
-            }
-            ContextServerStatus::Authenticating => AiSettingItemStatus::Authenticating,
-        };
-
-        let is_remote = server_configuration
-            .as_ref()
-            .map(|config| matches!(config.as_ref(), ContextServerConfiguration::Http { .. }))
-            .unwrap_or(false);
-
-        let should_show_logout_button = server_configuration.as_ref().is_some_and(|config| {
-            matches!(config.as_ref(), ContextServerConfiguration::Http { .. })
-                && !config.has_static_auth_header()
-        });
-
-        let context_server_configuration_menu = PopoverMenu::new("context-server-config-menu")
-            .trigger_with_tooltip(
-                IconButton::new("context-server-config-menu", IconName::Settings)
-                    .icon_color(Color::Muted)
-                    .icon_size(IconSize::Small),
-                Tooltip::text("Configure MCP Server"),
-            )
-            .anchor(Anchor::TopRight)
-            .menu({
-                let fs = self.fs.clone();
-                let context_server_id = context_server_id.clone();
-                let language_registry = self.language_registry.clone();
-                let workspace = self.workspace.clone();
-                let context_server_registry = self.context_server_registry.clone();
-                let context_server_store = context_server_store.clone();
-
-                move |window, cx| {
-                    Some(ContextMenu::build(window, cx, |menu, _window, _cx| {
-                        menu.entry("Configure Server", None, {
-                            let context_server_id = context_server_id.clone();
-                            let language_registry = language_registry.clone();
-                            let workspace = workspace.clone();
-                            move |window, cx| {
-                                if is_remote {
-                                    crate::agent_configuration::configure_context_server_modal::ConfigureContextServerModal::show_modal_for_existing_server(
-                                        context_server_id.clone(),
-                                        language_registry.clone(),
-                                        workspace.clone(),
-                                        window,
-                                        cx,
-                                    )
-                                    .detach();
-                                } else {
-                                    ConfigureContextServerModal::show_modal_for_existing_server(
-                                        context_server_id.clone(),
-                                        language_registry.clone(),
-                                        workspace.clone(),
-                                        window,
-                                        cx,
-                                    )
-                                    .detach();
-                                }
-                            }
-                        }).when(tool_count > 0, |this| this.entry("View Tools", None, {
-                            let context_server_id = context_server_id.clone();
-                            let context_server_registry = context_server_registry.clone();
-                            let workspace = workspace.clone();
-                            move |window, cx| {
-                                let context_server_id = context_server_id.clone();
-                                workspace.update(cx, |workspace, cx| {
-                                    ConfigureContextServerToolsModal::toggle(
-                                        context_server_id,
-                                        context_server_registry.clone(),
-                                        workspace,
-                                        window,
-                                        cx,
-                                    );
-                                })
-                                .ok();
-                            }
-                        }))
-                        .when(should_show_logout_button, |this| {
-                            this.entry("Log Out", None, {
-                                let context_server_store = context_server_store.clone();
-                                let context_server_id = context_server_id.clone();
-                                move |_window, cx| {
-                                    context_server_store.update(cx, |store, cx| {
-                                        store.logout_server(&context_server_id, cx).log_err();
-                                    });
-                                }
-                            })
-                        })
-                        .separator()
-                        .entry("Uninstall", None, {
-                            let fs = fs.clone();
-                            let context_server_id = context_server_id.clone();
-                            let workspace = workspace.clone();
-                            move |_, cx| {
-                                let uninstall_extension_task = match (
-                                    provided_by_extension,
-                                    resolve_extension_for_context_server(&context_server_id, cx),
-                                ) {
-                                    (true, Some((id, manifest))) => {
-                                        if extension_only_provides_context_server(manifest.as_ref())
-                                        {
-                                            ExtensionStore::global(cx).update(cx, |store, cx| {
-                                                store.uninstall_extension(id, cx)
-                                            })
-                                        } else {
-                                            workspace.update(cx, |workspace, cx| {
-                                                show_unable_to_uninstall_extension_with_context_server(workspace, context_server_id.clone(), cx);
-                                            }).log_err();
-                                            Task::ready(Ok(()))
-                                        }
-                                    }
-                                    _ => Task::ready(Ok(())),
-                                };
-
-                                cx.spawn({
-                                    let fs = fs.clone();
-                                    let context_server_id = context_server_id.clone();
-                                    async move |cx| {
-                                        uninstall_extension_task.await?;
-                                        cx.update(|cx| {
-                                            update_settings_file(
-                                                fs.clone(),
-                                                cx,
-                                                {
-                                                    let context_server_id =
-                                                        context_server_id.clone();
-                                                    move |settings, _| {
-                                                        settings.project
-                                                            .context_servers
-                                                            .remove(&context_server_id.0);
-                                                    }
-                                                },
-                                            )
-                                        });
-                                        anyhow::Ok(())
-                                    }
-                                })
-                                .detach_and_log_err(cx);
-                            }
-                        })
-                    }))
-                }
-            });
-
-        let feedback_base_container =
-            || h_flex().py_1().min_w_0().w_full().gap_1().justify_between();
-
-        let details: Option = if let Some(error) = error {
-            Some(
-                feedback_base_container()
-                    .child(
-                        h_flex()
-                            .pr_4()
-                            .min_w_0()
-                            .w_full()
-                            .gap_2()
-                            .child(
-                                Icon::new(IconName::XCircle)
-                                    .size(IconSize::XSmall)
-                                    .color(Color::Error),
-                            )
-                            .child(div().min_w_0().flex_1().child(
-                                Label::new(error).color(Color::Muted).size(LabelSize::Small),
-                            )),
-                    )
-                    .when(should_show_logout_button, |this| {
-                        this.child(
-                            Button::new("error-logout-server", "Log Out")
-                                .style(ButtonStyle::Outlined)
-                                .label_size(LabelSize::Small)
-                                .on_click({
-                                    let context_server_store = context_server_store.clone();
-                                    let context_server_id = context_server_id.clone();
-                                    move |_event, _window, cx| {
-                                        context_server_store.update(cx, |store, cx| {
-                                            store.logout_server(&context_server_id, cx).log_err();
-                                        });
-                                    }
-                                }),
-                        )
-                    })
-                    .into_any_element(),
-            )
-        } else if auth_required {
-            Some(
-                feedback_base_container()
-                    .child(
-                        h_flex()
-                            .pr_4()
-                            .min_w_0()
-                            .w_full()
-                            .gap_2()
-                            .child(
-                                Icon::new(IconName::Info)
-                                    .size(IconSize::XSmall)
-                                    .color(Color::Muted),
-                            )
-                            .child(
-                                Label::new("Authenticate to connect this server")
-                                    .color(Color::Muted)
-                                    .size(LabelSize::Small),
-                            ),
-                    )
-                    .child(
-                        Button::new("authenticate-server", "Authenticate")
-                            .style(ButtonStyle::Outlined)
-                            .label_size(LabelSize::Small)
-                            .on_click({
-                                let context_server_id = context_server_id.clone();
-                                move |_event, _window, cx| {
-                                    context_server_store.update(cx, |store, cx| {
-                                        store.authenticate_server(&context_server_id, cx).log_err();
-                                    });
-                                }
-                            }),
-                    )
-                    .into_any_element(),
-            )
-        } else if client_secret_required {
-            Some(
-                feedback_base_container()
-                    .child(
-                        h_flex()
-                            .pr_4()
-                            .min_w_0()
-                            .w_full()
-                            .gap_2()
-                            .child(
-                                Icon::new(IconName::Info)
-                                    .size(IconSize::XSmall)
-                                    .color(Color::Muted),
-                            )
-                            .child(
-                                Label::new("Enter a client secret to connect this server")
-                                    .color(Color::Muted)
-                                    .size(LabelSize::Small),
-                            ),
-                    )
-                    .child(
-                        Button::new("enter-client-secret", "Enter Client Secret")
-                            .style(ButtonStyle::Outlined)
-                            .label_size(LabelSize::Small)
-                            .on_click({
-                                let context_server_id = context_server_id.clone();
-                                move |_event, window, cx| {
-                                    ConfigureContextServerModal::show_modal_for_existing_server(
-                                        context_server_id.clone(),
-                                        language_registry.clone(),
-                                        workspace.clone(),
-                                        window,
-                                        cx,
-                                    )
-                                    .detach();
-                                }
-                            }),
-                    )
-                    .into_any_element(),
-            )
-        } else if authenticating {
-            Some(
-                h_flex()
-                    .mt_1()
-                    .pr_4()
-                    .min_w_0()
-                    .w_full()
-                    .gap_2()
-                    .child(div().size_3().flex_shrink_0())
-                    .child(
-                        Label::new("Authenticating…")
-                            .color(Color::Muted)
-                            .size(LabelSize::Small),
-                    )
-                    .into_any_element(),
-            )
-        } else {
-            None
-        };
-
-        let tool_label = if is_running {
-            Some(if tool_count == 1 {
-                SharedString::from("1 tool")
-            } else {
-                SharedString::from(format!("{} tools", tool_count))
-            })
-        } else {
-            None
-        };
-
-        AiSettingItem::new(item_id, display_name, status, source)
-            .action(context_server_configuration_menu)
-            .action(
-                Switch::new("context-server-switch", is_running.into()).on_click({
-                    let context_server_manager = self.context_server_store.clone();
-                    let fs = self.fs.clone();
-
-                    move |state, _window, cx| {
-                        let is_enabled = match state {
-                            ToggleState::Unselected | ToggleState::Indeterminate => {
-                                context_server_manager.update(cx, |this, cx| {
-                                    this.stop_server(&context_server_id, cx).log_err();
-                                });
-                                false
-                            }
-                            ToggleState::Selected => {
-                                context_server_manager.update(cx, |this, cx| {
-                                    if let Some(server) = this.get_server(&context_server_id) {
-                                        this.start_server(server, cx);
-                                    }
-                                });
-                                true
-                            }
-                        };
-                        update_settings_file(fs.clone(), cx, {
-                            let context_server_id = context_server_id.clone();
-
-                            move |settings, _| {
-                                settings
-                                    .project
-                                    .context_servers
-                                    .entry(context_server_id.0)
-                                    .or_insert_with(|| {
-                                        settings::ContextServerSettingsContent::Extension {
-                                            enabled: is_enabled,
-                                            remote: false,
-                                            settings: serde_json::json!({}),
-                                        }
-                                    })
-                                    .set_enabled(is_enabled);
-                            }
-                        });
-                    }
-                }),
-            )
-            .when_some(tool_label, |this, label| this.detail_label(label))
-            .when_some(details, |this, details| this.details(details))
-    }
-
-    fn render_agent_servers_section(&mut self, cx: &mut Context) -> impl IntoElement {
-        let agent_server_store = self.agent_server_store.read(cx);
-
-        let agents = agent_server_store
-            .external_agents()
-            .cloned()
-            .collect::>();
-
-        let agents: Vec<_> = agents
-            .into_iter()
-            .map(|name| {
-                let icon = if let Some(icon_path) = agent_server_store.agent_icon(&name) {
-                    AgentIcon::Path(icon_path)
-                } else {
-                    AgentIcon::Name(IconName::Sparkle)
-                };
-                let display_name = agent_server_store
-                    .agent_display_name(&name)
-                    .unwrap_or_else(|| name.0.clone());
-                let source = agent_server_store.agent_source(&name).unwrap_or_default();
-                (name, icon, display_name, source)
-            })
-            .sorted_unstable_by_key(|(_, _, display_name, _)| display_name.to_lowercase())
-            .collect();
-
-        let add_agent_popover = PopoverMenu::new("add-agent-server-popover")
-            .trigger(
-                Button::new("add-agent", "Add Agent")
-                    .style(ButtonStyle::Outlined)
-                    .start_icon(
-                        Icon::new(IconName::Plus)
-                            .size(IconSize::Small)
-                            .color(Color::Muted),
-                    )
-                    .label_size(LabelSize::Small),
-            )
-            .menu({
-                move |window, cx| {
-                    Some(ContextMenu::build(window, cx, |menu, _window, _cx| {
-                        menu.entry("Install from Registry", None, {
-                            |window, cx| {
-                                window.dispatch_action(Box::new(zed_actions::AcpRegistry), cx)
-                            }
-                        })
-                        .entry("Add Custom Agent", None, {
-                            move |window, cx| {
-                                if let Some(workspace) = Workspace::for_window(window, cx) {
-                                    let workspace = workspace.downgrade();
-                                    window
-                                        .spawn(cx, async |cx| {
-                                            open_new_agent_servers_entry_in_settings_editor(
-                                                workspace, cx,
-                                            )
-                                            .await
-                                        })
-                                        .detach_and_log_err(cx);
-                                }
-                            }
-                        })
-                        .separator()
-                        .header("Learn More")
-                        .item(
-                            ContextMenuEntry::new("ACP Docs")
-                                .icon(IconName::ArrowUpRight)
-                                .icon_color(Color::Muted)
-                                .icon_position(IconPosition::End)
-                                .handler({
-                                    move |window, cx| {
-                                        window.dispatch_action(
-                                            Box::new(OpenBrowser {
-                                                url: "https://agentclientprotocol.com/".into(),
-                                            }),
-                                            cx,
-                                        );
-                                    }
-                                }),
-                        )
-                    }))
-                }
-            })
-            .anchor(gpui::Anchor::TopRight)
-            .offset(gpui::Point {
-                x: px(0.0),
-                y: px(2.0),
-            });
-
-        v_flex()
-            .min_w_0()
-            .border_b_1()
-            .border_color(cx.theme().colors().border)
-            .child(
-                v_flex()
-                    .child(self.render_section_title(
-                        "External Agents",
-                        "All agents connected through the Agent Client Protocol.",
-                        add_agent_popover.into_any_element(),
-                    ))
-                    .child(
-                        v_flex()
-                            .p_4()
-                            .pt_0()
-                            .gap_2()
-                            .children(Itertools::intersperse_with(
-                                agents
-                                    .into_iter()
-                                    .map(|(name, icon, display_name, source)| {
-                                        self.render_agent_server(
-                                            icon,
-                                            name,
-                                            display_name,
-                                            source,
-                                            cx,
-                                        )
-                                        .into_any_element()
-                                    }),
-                                || {
-                                    Divider::horizontal()
-                                        .color(DividerColor::BorderFaded)
-                                        .into_any_element()
-                                },
-                            )),
-                    ),
-            )
-    }
-
-    fn render_agent_server(
-        &self,
-        icon: AgentIcon,
-        id: impl Into,
-        display_name: impl Into,
-        source: ExternalAgentSource,
-        cx: &mut Context,
-    ) -> impl IntoElement {
-        let id = id.into();
-        let display_name = display_name.into();
-
-        let icon = match icon {
-            AgentIcon::Name(icon_name) => Icon::new(icon_name)
-                .size(IconSize::Small)
-                .color(Color::Muted),
-            AgentIcon::Path(icon_path) => Icon::from_external_svg(icon_path)
-                .size(IconSize::Small)
-                .color(Color::Muted),
-        };
-
-        let source_kind = match source {
-            ExternalAgentSource::Registry => AiSettingItemSource::Registry,
-            ExternalAgentSource::Custom => AiSettingItemSource::Custom,
-        };
-
-        let agent_server_name = AgentId(id.clone());
-        let agent = Agent::Custom {
-            id: agent_server_name.clone(),
-        };
-
-        let (connection_status, running_version) = {
-            let connection_store = self.agent_connection_store.read(cx);
-            (
-                connection_store.connection_status(&agent, cx),
-                connection_store.agent_version(&agent, cx),
-            )
-        };
-
-        let restart_button = matches!(
-            connection_status,
-            AgentConnectionStatus::Connected | AgentConnectionStatus::Connecting
-        )
-        .then(|| {
-            IconButton::new(
-                SharedString::from(format!("restart-{}", id)),
-                IconName::RotateCw,
-            )
-            .disabled(connection_status == AgentConnectionStatus::Connecting)
-            .icon_color(Color::Muted)
-            .icon_size(IconSize::Small)
-            .tooltip(Tooltip::text("Restart Agent Connection"))
-            .on_click(cx.listener({
-                let agent = agent.clone();
-                move |this, _, _window, cx| {
-                    let server: Rc =
-                        Rc::new(agent_servers::CustomAgentServer::new(agent.id()));
-                    this.agent_connection_store.update(cx, |store, cx| {
-                        store.restart_connection(agent.clone(), server, cx);
-                    });
-                }
-            }))
-        });
-
-        let uninstall_button = match source {
-            ExternalAgentSource::Registry => {
-                let fs = self.fs.clone();
-                Some(
-                    IconButton::new(
-                        SharedString::from(format!("uninstall-{}", id)),
-                        IconName::Trash,
-                    )
-                    .icon_color(Color::Muted)
-                    .icon_size(IconSize::Small)
-                    .tooltip(Tooltip::text("Remove Registry Agent"))
-                    .on_click(cx.listener(move |_, _, _window, cx| {
-                        let agent_name = agent_server_name.clone();
-                        update_settings_file(fs.clone(), cx, move |settings, _| {
-                            let Some(agent_servers) = settings.agent_servers.as_mut() else {
-                                return;
-                            };
-                            if let Some(entry) = agent_servers.get(agent_name.0.as_ref())
-                                && matches!(
-                                    entry,
-                                    settings::CustomAgentServerSettings::Registry { .. }
-                                )
-                            {
-                                agent_servers.remove(agent_name.0.as_ref());
-                            }
-                        });
-                    })),
-                )
-            }
-            ExternalAgentSource::Custom => {
-                let fs = self.fs.clone();
-                Some(
-                    IconButton::new(
-                        SharedString::from(format!("uninstall-{}", id)),
-                        IconName::Trash,
-                    )
-                    .icon_color(Color::Muted)
-                    .icon_size(IconSize::Small)
-                    .tooltip(Tooltip::text("Remove Custom Agent"))
-                    .on_click(cx.listener(move |_, _, _window, cx| {
-                        let agent_name = agent_server_name.clone();
-                        update_settings_file(fs.clone(), cx, move |settings, _| {
-                            let Some(agent_servers) = settings.agent_servers.as_mut() else {
-                                return;
-                            };
-                            if let Some(entry) = agent_servers.get(agent_name.0.as_ref())
-                                && matches!(
-                                    entry,
-                                    settings::CustomAgentServerSettings::Custom { .. }
-                                )
-                            {
-                                agent_servers.remove(agent_name.0.as_ref());
-                            }
-                        });
-                    })),
-                )
-            }
-        };
-
-        let status = match connection_status {
-            AgentConnectionStatus::Disconnected => AiSettingItemStatus::Stopped,
-            AgentConnectionStatus::Connecting => AiSettingItemStatus::Starting,
-            AgentConnectionStatus::Connected => AiSettingItemStatus::Running,
-        };
-
-        AiSettingItem::new(id, display_name, status, source_kind)
-            .icon(icon)
-            .when_some(running_version, |this, version| this.detail_label(version))
-            .when_some(restart_button, |this, button| this.action(button))
-            .when_some(uninstall_button, |this, button| this.action(button))
-    }
-}
-
-impl Render for AgentConfiguration {
-    fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
-        v_flex()
-            .id("assistant-configuration")
-            .key_context("AgentConfiguration")
-            .track_focus(&self.focus_handle(cx))
-            .relative()
-            .size_full()
-            .pb_8()
-            .bg(cx.theme().colors().panel_background)
-            .child(
-                div()
-                    .size_full()
-                    .child(
-                        v_flex()
-                            .id("assistant-configuration-content")
-                            .track_scroll(&self.scroll_handle)
-                            .size_full()
-                            .min_w_0()
-                            .overflow_y_scroll()
-                            .child(self.render_agent_servers_section(cx))
-                            .child(self.render_context_servers_section(cx))
-                            .child(self.render_provider_configuration_section(cx)),
-                    )
-                    .vertical_scrollbar_for(&self.scroll_handle, window, cx),
-            )
-    }
-}
-
-fn extension_only_provides_context_server(manifest: &ExtensionManifest) -> bool {
-    manifest.context_servers.len() == 1
-        && manifest.themes.is_empty()
-        && manifest.icon_themes.is_empty()
-        && manifest.languages.is_empty()
-        && manifest.grammars.is_empty()
-        && manifest.language_servers.is_empty()
-        && manifest.slash_commands.is_empty()
-        && manifest.snippets.is_none()
-        && manifest.debug_locators.is_empty()
-}
-
-pub(crate) fn resolve_extension_for_context_server(
-    id: &ContextServerId,
-    cx: &App,
-) -> Option<(Arc, Arc)> {
-    ExtensionStore::global(cx)
-        .read(cx)
-        .installed_extensions()
-        .iter()
-        .find(|(_, entry)| entry.manifest.context_servers.contains_key(&id.0))
-        .map(|(id, entry)| (id.clone(), entry.manifest.clone()))
-}
-
-// This notification appears when trying to delete
-// an MCP server extension that not only provides
-// the server, but other things, too, like language servers and more.
-fn show_unable_to_uninstall_extension_with_context_server(
-    workspace: &mut Workspace,
-    id: ContextServerId,
-    cx: &mut App,
-) {
-    let workspace_handle = workspace.weak_handle();
-    let context_server_id = id.clone();
-
-    let status_toast = StatusToast::new(
-        format!(
-            "The {} extension provides more than just the MCP server. Proceed to uninstall anyway?",
-            id.0
-        ),
-        cx,
-        move |this, _cx| {
-            let workspace_handle = workspace_handle.clone();
-
-            this.icon(
-                Icon::new(IconName::Warning)
-                    .size(IconSize::Small)
-                    .color(Color::Warning),
-            )
-            .dismiss_button(true)
-            .action("Uninstall", move |_, _cx| {
-                if let Some((extension_id, _)) =
-                    resolve_extension_for_context_server(&context_server_id, _cx)
-                {
-                    ExtensionStore::global(_cx).update(_cx, |store, cx| {
-                        store
-                            .uninstall_extension(extension_id, cx)
-                            .detach_and_log_err(cx);
-                    });
-
-                    workspace_handle
-                        .update(_cx, |workspace, cx| {
-                            let fs = workspace.app_state().fs.clone();
-                            cx.spawn({
-                                let context_server_id = context_server_id.clone();
-                                async move |_workspace_handle, cx| {
-                                    cx.update(|cx| {
-                                        update_settings_file(fs, cx, move |settings, _| {
-                                            settings
-                                                .project
-                                                .context_servers
-                                                .remove(&context_server_id.0);
-                                        });
-                                    });
-                                    anyhow::Ok(())
-                                }
-                            })
-                            .detach_and_log_err(cx);
-                        })
-                        .log_err();
-                }
-            })
-        },
-    );
-
-    workspace.toggle_status_toast(status_toast, cx);
-}
-
-async fn open_new_agent_servers_entry_in_settings_editor(
-    workspace: WeakEntity,
-    cx: &mut AsyncWindowContext,
-) -> Result<()> {
-    let settings_editor = workspace
-        .update_in(cx, |_, window, cx| {
-            create_and_open_local_file(paths::settings_file(), window, cx, || {
-                settings::initial_user_settings_content().as_ref().into()
-            })
-        })?
-        .await?
-        .downcast::()
-        .unwrap();
-
-    settings_editor
-        .downgrade()
-        .update_in(cx, |item, window, cx| {
-            let text = item.buffer().read(cx).snapshot(cx).text();
-
-            let settings = cx.global::();
-
-            let mut unique_server_name = None;
-            let Some(edits) = settings
-                .edits_for_update(&text, |settings| {
-                    let server_name: Option = (0..u8::MAX)
-                        .map(|i| {
-                            if i == 0 {
-                                "your_agent".to_string()
-                            } else {
-                                format!("your_agent_{}", i)
-                            }
-                        })
-                        .find(|name| {
-                            !settings
-                                .agent_servers
-                                .as_ref()
-                                .is_some_and(|agent_servers| {
-                                    agent_servers.contains_key(name.as_str())
-                                })
-                        });
-                    if let Some(server_name) = server_name {
-                        unique_server_name = Some(SharedString::from(server_name.clone()));
-                        settings.agent_servers.get_or_insert_default().insert(
-                            server_name,
-                            settings::CustomAgentServerSettings::Custom {
-                                path: "path_to_executable".into(),
-                                args: vec![],
-                                env: HashMap::default(),
-                                default_mode: None,
-                                default_config_options: Default::default(),
-                                favorite_config_option_values: Default::default(),
-                            },
-                        );
-                    }
-                })
-                .log_err()
-            else {
-                return;
-            };
-
-            if edits.is_empty() {
-                return;
-            }
-
-            let ranges = edits
-                .iter()
-                .map(|(range, _)| range.clone())
-                .collect::>();
-
-            item.edit(
-                edits.into_iter().map(|(range, s)| {
-                    (
-                        MultiBufferOffset(range.start)..MultiBufferOffset(range.end),
-                        s,
-                    )
-                }),
-                cx,
-            );
-            if let Some((unique_server_name, buffer)) =
-                unique_server_name.zip(item.buffer().read(cx).as_singleton())
-            {
-                let snapshot = buffer.read(cx).snapshot();
-                if let Some(range) =
-                    find_text_in_buffer(&unique_server_name, ranges[0].start, &snapshot)
-                {
-                    item.change_selections(
-                        SelectionEffects::scroll(Autoscroll::newest()),
-                        window,
-                        cx,
-                        |selections| {
-                            selections.select_ranges(vec![
-                                MultiBufferOffset(range.start)..MultiBufferOffset(range.end),
-                            ]);
-                        },
-                    );
-                }
-            }
-        })
-}
-
-fn find_text_in_buffer(
-    text: &str,
-    start: usize,
-    snapshot: &language::BufferSnapshot,
-) -> Option> {
-    let chars = text.chars().collect::>();
-
-    let mut offset = start;
-    let mut char_offset = 0;
-    for c in snapshot.chars_at(start) {
-        if char_offset >= chars.len() {
-            break;
-        }
-        offset += 1;
-
-        if c == chars[char_offset] {
-            char_offset += 1;
-        } else {
-            char_offset = 0;
-        }
-    }
-
-    if char_offset == chars.len() {
-        Some(offset.saturating_sub(chars.len())..offset)
-    } else {
-        None
-    }
-}
-
-// API-compatible providers are user-configured and can be removed,
-// whereas built-in providers (like Anthropic, OpenAI, Google, etc.) can't.
-//
-// If in the future we have more "API-compatible-type" of providers,
-// they should be included here as removable providers.
-fn is_removable_provider(provider_id: &LanguageModelProviderId, cx: &App) -> bool {
-    let settings = AllLanguageModelSettings::get_global(cx);
-    settings
-        .openai_compatible
-        .contains_key(provider_id.0.as_ref())
-        || settings
-            .anthropic_compatible
-            .contains_key(provider_id.0.as_ref())
-}
-
-fn remove_compatible_provider(settings: &mut SettingsContent, provider_id: &str) {
-    // Mirrors the OpenAI-wins precedence used at registration time: only the
-    // entry that is actually registered gets removed. A shadowed
-    // `anthropic_compatible` entry with the same name takes over instead of
-    // being silently deleted.
-    let Some(language_models) = settings.language_models.as_mut() else {
-        return;
-    };
-    let removed_from_openai = language_models
-        .openai_compatible
-        .as_mut()
-        .and_then(|providers| providers.remove(provider_id))
-        .is_some();
-    if !removed_from_openai && let Some(providers) = language_models.anthropic_compatible.as_mut() {
-        providers.remove(provider_id);
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use settings::{AnthropicCompatibleSettingsContent, OpenAiCompatibleSettingsContent};
-
-    fn settings_with_compatible_providers(openai: &[&str], anthropic: &[&str]) -> SettingsContent {
-        let mut settings = SettingsContent::default();
-        let language_models = settings.language_models.get_or_insert_default();
-        language_models.openai_compatible = Some(
-            openai
-                .iter()
-                .map(|id| {
-                    (
-                        Arc::from(*id),
-                        OpenAiCompatibleSettingsContent {
-                            api_url: "https://example.com".to_string(),
-                            available_models: Vec::new(),
-                            custom_headers: None,
-                        },
-                    )
-                })
-                .collect(),
-        );
-        language_models.anthropic_compatible = Some(
-            anthropic
-                .iter()
-                .map(|id| {
-                    (
-                        Arc::from(*id),
-                        AnthropicCompatibleSettingsContent {
-                            api_url: "https://example.com".to_string(),
-                            available_models: Vec::new(),
-                            custom_headers: None,
-                        },
-                    )
-                })
-                .collect(),
-        );
-        settings
-    }
-
-    fn compatible_provider_keys(settings: &SettingsContent) -> (Vec<&str>, Vec<&str>) {
-        fn keys(providers: Option<&HashMap, T>>) -> Vec<&str> {
-            providers
-                .map(|providers| providers.keys().map(|key| key.as_ref()).collect())
-                .unwrap_or_default()
-        }
-
-        let language_models = settings
-            .language_models
-            .as_ref()
-            .expect("language_models settings should exist");
-        (
-            keys(language_models.openai_compatible.as_ref()),
-            keys(language_models.anthropic_compatible.as_ref()),
-        )
-    }
-
-    #[test]
-    fn test_remove_compatible_provider_openai_only() {
-        let mut settings = settings_with_compatible_providers(&["acme"], &[]);
-        remove_compatible_provider(&mut settings, "acme");
-        let (openai, anthropic) = compatible_provider_keys(&settings);
-        assert_eq!(openai, Vec::<&str>::new());
-        assert_eq!(anthropic, Vec::<&str>::new());
-    }
-
-    #[test]
-    fn test_remove_compatible_provider_anthropic_only() {
-        let mut settings = settings_with_compatible_providers(&[], &["acme"]);
-        remove_compatible_provider(&mut settings, "acme");
-        let (openai, anthropic) = compatible_provider_keys(&settings);
-        assert_eq!(openai, Vec::<&str>::new());
-        assert_eq!(anthropic, Vec::<&str>::new());
-    }
-
-    #[test]
-    fn test_remove_compatible_provider_collision_removes_only_openai_entry() {
-        let mut settings = settings_with_compatible_providers(&["acme"], &["acme"]);
-
-        remove_compatible_provider(&mut settings, "acme");
-        let (openai, anthropic) = compatible_provider_keys(&settings);
-        assert_eq!(
-            openai,
-            Vec::<&str>::new(),
-            "the registered (OpenAI-compatible) entry should be removed"
-        );
-        assert_eq!(
-            anthropic,
-            vec!["acme"],
-            "the shadowed anthropic_compatible entry should survive"
-        );
-
-        // A second removal deletes the entry that took over.
-        remove_compatible_provider(&mut settings, "acme");
-        let (_, anthropic) = compatible_provider_keys(&settings);
-        assert_eq!(anthropic, Vec::<&str>::new());
-    }
-
-    #[test]
-    fn test_remove_compatible_provider_leaves_other_providers_untouched() {
-        let mut settings = settings_with_compatible_providers(&["acme", "globex"], &["initech"]);
-        remove_compatible_provider(&mut settings, "acme");
-        let (openai, anthropic) = compatible_provider_keys(&settings);
-        assert_eq!(openai, vec!["globex"]);
-        assert_eq!(anthropic, vec!["initech"]);
-    }
-}
diff --git a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs
deleted file mode 100644
index d1f281bbca44c3..00000000000000
--- a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs
+++ /dev/null
@@ -1,1201 +0,0 @@
-use std::sync::Arc;
-
-use anyhow::Result;
-use fs::Fs;
-use gpui::{
-    DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, ScrollHandle, Task, TaskExt,
-};
-use itertools::Itertools as _;
-use language_model::LanguageModelRegistry;
-use language_models::provider::open_ai_compatible::{
-    AvailableModel as OpenAiCompatibleAvailableModel,
-    ModelCapabilities as OpenAiCompatibleModelCapabilities,
-};
-use settings::{
-    AnthropicCompatibleAvailableModel, AnthropicCompatibleModelCapabilities,
-    AnthropicCompatibleSettingsContent, OpenAiCompatibleSettingsContent, OpenAiReasoningEffort,
-    update_settings_file,
-};
-use ui::{
-    Banner, Checkbox, ContextMenu, ContextMenuEntry, DropdownMenu, DropdownStyle, IconPosition,
-    KeyBinding, Modal, ModalFooter, ModalHeader, Section, ToggleState, WithScrollbar, prelude::*,
-};
-use ui_input::InputField;
-use workspace::{ModalView, Workspace};
-
-fn single_line_input(
-    label: impl Into,
-    placeholder: &str,
-    text: Option<&str>,
-    tab_index: isize,
-    window: &mut Window,
-    cx: &mut App,
-) -> Entity {
-    cx.new(|cx| {
-        let input = InputField::new(window, cx, placeholder)
-            .label(label)
-            .tab_index(tab_index)
-            .tab_stop(true);
-
-        if let Some(text) = text {
-            input.set_text(text, window, cx);
-        }
-        input
-    })
-}
-
-#[derive(Clone, Copy)]
-pub enum LlmCompatibleProvider {
-    OpenAi,
-    Anthropic,
-}
-
-impl LlmCompatibleProvider {
-    fn name(&self) -> &'static str {
-        match self {
-            LlmCompatibleProvider::OpenAi => "OpenAI",
-            LlmCompatibleProvider::Anthropic => "Anthropic",
-        }
-    }
-
-    fn api_url(&self) -> &'static str {
-        match self {
-            LlmCompatibleProvider::OpenAi => "https://api.openai.com/v1",
-            LlmCompatibleProvider::Anthropic => "https://api.anthropic.com",
-        }
-    }
-
-    fn description(&self) -> &'static str {
-        match self {
-            LlmCompatibleProvider::OpenAi => "This provider will use an OpenAI compatible API.",
-            LlmCompatibleProvider::Anthropic => {
-                "This provider will use an Anthropic Messages compatible API."
-            }
-        }
-    }
-
-    fn is_open_ai(&self) -> bool {
-        matches!(self, LlmCompatibleProvider::OpenAi)
-    }
-}
-
-struct AddLlmProviderInput {
-    provider_name: Entity,
-    api_url: Entity,
-    api_key: Entity,
-    models: Vec,
-}
-
-impl AddLlmProviderInput {
-    fn new(provider: LlmCompatibleProvider, window: &mut Window, cx: &mut App) -> Self {
-        let provider_name =
-            single_line_input("Provider Name", provider.name(), None, 1, window, cx);
-        let api_url = single_line_input("API URL", provider.api_url(), None, 2, window, cx);
-        let api_key = cx.new(|cx| {
-            InputField::new(
-                window,
-                cx,
-                "000000000000000000000000000000000000000000000000",
-            )
-            .label("API Key")
-            .tab_index(3)
-            .tab_stop(true)
-            .masked(true)
-        });
-
-        Self {
-            provider_name,
-            api_url,
-            api_key,
-            models: vec![ModelInput::new(0, window, cx)],
-        }
-    }
-
-    fn add_model(&mut self, window: &mut Window, cx: &mut App) {
-        let model_index = self.models.len();
-        self.models.push(ModelInput::new(model_index, window, cx));
-    }
-
-    fn remove_model(&mut self, index: usize) {
-        self.models.remove(index);
-    }
-}
-
-struct ModelCapabilityToggles {
-    pub supports_tools: ToggleState,
-    pub supports_images: ToggleState,
-    pub supports_parallel_tool_calls: ToggleState,
-    pub supports_prompt_cache_key: ToggleState,
-    pub supports_chat_completions: ToggleState,
-    pub supports_thinking: ToggleState,
-    pub interleaved_reasoning: ToggleState,
-    pub max_tokens_parameter: ToggleState,
-}
-
-struct ModelInput {
-    name: Entity,
-    max_completion_tokens: Entity,
-    max_output_tokens: Entity,
-    max_tokens: Entity,
-    reasoning_effort: OpenAiReasoningEffort,
-    capabilities: ModelCapabilityToggles,
-}
-
-impl ModelInput {
-    fn new(model_index: usize, window: &mut Window, cx: &mut App) -> Self {
-        let base_tab_index = (3 + (model_index * 4)) as isize;
-
-        let model_name = single_line_input(
-            "Model Name",
-            "e.g. gpt-5, claude-opus-4, gemini-2.5-pro",
-            None,
-            base_tab_index + 1,
-            window,
-            cx,
-        );
-        let max_completion_tokens = single_line_input(
-            "Max Completion Tokens",
-            "200000",
-            Some("200000"),
-            base_tab_index + 2,
-            window,
-            cx,
-        );
-        let max_output_tokens = single_line_input(
-            "Max Output Tokens",
-            "Max Output Tokens",
-            Some("32000"),
-            base_tab_index + 3,
-            window,
-            cx,
-        );
-        let max_tokens = single_line_input(
-            "Max Tokens",
-            "Max Tokens",
-            Some("200000"),
-            base_tab_index + 4,
-            window,
-            cx,
-        );
-
-        let OpenAiCompatibleModelCapabilities {
-            tools,
-            images,
-            parallel_tool_calls,
-            prompt_cache_key,
-            chat_completions,
-            interleaved_reasoning,
-            max_tokens_parameter,
-            ..
-        } = OpenAiCompatibleModelCapabilities::default();
-
-        Self {
-            name: model_name,
-            max_completion_tokens,
-            max_output_tokens,
-            max_tokens,
-            capabilities: ModelCapabilityToggles {
-                supports_tools: tools.into(),
-                supports_images: images.into(),
-                supports_parallel_tool_calls: parallel_tool_calls.into(),
-                supports_prompt_cache_key: prompt_cache_key.into(),
-                supports_chat_completions: chat_completions.into(),
-                supports_thinking: ToggleState::Unselected,
-                interleaved_reasoning: interleaved_reasoning.into(),
-                max_tokens_parameter: max_tokens_parameter.into(),
-            },
-            reasoning_effort: OpenAiReasoningEffort::Medium,
-        }
-    }
-
-    fn parse_name(&self, cx: &App) -> Result {
-        let name = self.name.read(cx).text(cx);
-        if name.is_empty() {
-            return Err(SharedString::from("Model Name cannot be empty"));
-        }
-        Ok(name)
-    }
-
-    fn parse_open_ai_compatible(
-        &self,
-        cx: &App,
-    ) -> Result {
-        Ok(OpenAiCompatibleAvailableModel {
-            name: self.parse_name(cx)?,
-            display_name: None,
-            max_completion_tokens: Some(parse_u64_field(
-                &self.max_completion_tokens,
-                "Max Completion Tokens",
-                cx,
-            )?),
-            max_output_tokens: Some(parse_u64_field(
-                &self.max_output_tokens,
-                "Max Output Tokens",
-                cx,
-            )?),
-            max_tokens: parse_u64_field(&self.max_tokens, "Max Tokens", cx)?,
-            reasoning_effort: {
-                if self.capabilities.supports_thinking.selected() {
-                    Some(self.reasoning_effort)
-                } else {
-                    None
-                }
-            },
-            capabilities: OpenAiCompatibleModelCapabilities {
-                tools: self.capabilities.supports_tools.selected(),
-                images: self.capabilities.supports_images.selected(),
-                parallel_tool_calls: self.capabilities.supports_parallel_tool_calls.selected(),
-                prompt_cache_key: self.capabilities.supports_prompt_cache_key.selected(),
-                chat_completions: self.capabilities.supports_chat_completions.selected(),
-                interleaved_reasoning: self.capabilities.supports_thinking.selected()
-                    && self.capabilities.supports_chat_completions.selected()
-                    && self.capabilities.interleaved_reasoning.selected(),
-                max_tokens_parameter: self.capabilities.supports_chat_completions.selected()
-                    && self.capabilities.max_tokens_parameter.selected(),
-            },
-        })
-    }
-
-    fn parse_anthropic_compatible(
-        &self,
-        cx: &App,
-    ) -> Result {
-        Ok(AnthropicCompatibleAvailableModel {
-            name: self.parse_name(cx)?,
-            display_name: None,
-            max_tokens: parse_u64_field(&self.max_tokens, "Max Tokens", cx)?,
-            tool_override: None,
-            max_output_tokens: Some(parse_u64_field(
-                &self.max_output_tokens,
-                "Max Output Tokens",
-                cx,
-            )?),
-            default_temperature: None,
-            extra_beta_headers: Vec::new(),
-            mode: None,
-            capabilities: AnthropicCompatibleModelCapabilities {
-                tools: self.capabilities.supports_tools.selected(),
-                images: self.capabilities.supports_images.selected(),
-                prompt_caching: false,
-            },
-        })
-    }
-}
-
-fn parse_u64_field(
-    field: &Entity,
-    field_name: &str,
-    cx: &App,
-) -> Result {
-    field
-        .read(cx)
-        .text(cx)
-        .parse::()
-        .map_err(|_| SharedString::from(format!("{field_name} must be a number")))
-}
-
-enum ParsedModels {
-    OpenAi(Vec),
-    Anthropic(Vec),
-}
-
-impl ParsedModels {
-    fn model_names(&self) -> impl Iterator {
-        match self {
-            ParsedModels::OpenAi(models) => {
-                itertools::Either::Left(models.iter().map(|model| model.name.as_str()))
-            }
-            ParsedModels::Anthropic(models) => {
-                itertools::Either::Right(models.iter().map(|model| model.name.as_str()))
-            }
-        }
-    }
-}
-
-fn save_provider_to_settings(
-    provider: LlmCompatibleProvider,
-    input: &AddLlmProviderInput,
-    cx: &mut App,
-) -> Task> {
-    let provider_name: Arc = input.provider_name.read(cx).text(cx).into();
-    if provider_name.is_empty() {
-        return Task::ready(Err("Provider Name cannot be empty".into()));
-    }
-
-    if LanguageModelRegistry::read_global(cx)
-        .providers()
-        .iter()
-        .any(|provider| {
-            provider.id().0.as_ref() == provider_name.as_ref()
-                || provider.name().0.as_ref() == provider_name.as_ref()
-        })
-    {
-        return Task::ready(Err(
-            "Provider Name is already taken by another provider".into()
-        ));
-    }
-
-    let api_url = input.api_url.read(cx).text(cx);
-    if api_url.is_empty() {
-        return Task::ready(Err("API URL cannot be empty".into()));
-    }
-
-    let api_key = input.api_key.read(cx).text(cx);
-    if api_key.is_empty() {
-        return Task::ready(Err("API Key cannot be empty".into()));
-    }
-
-    let models = match provider {
-        LlmCompatibleProvider::OpenAi => input
-            .models
-            .iter()
-            .map(|model| model.parse_open_ai_compatible(cx))
-            .collect::, _>>()
-            .map(ParsedModels::OpenAi),
-        LlmCompatibleProvider::Anthropic => input
-            .models
-            .iter()
-            .map(|model| model.parse_anthropic_compatible(cx))
-            .collect::, _>>()
-            .map(ParsedModels::Anthropic),
-    };
-    let models = match models {
-        Ok(models) => models,
-        Err(error) => return Task::ready(Err(error)),
-    };
-
-    if !models.model_names().all_unique() {
-        return Task::ready(Err("Model Names must be unique".into()));
-    }
-
-    let fs = ::global(cx);
-    let task = cx.write_credentials(&api_url, "Bearer", api_key.as_bytes());
-    cx.spawn(async move |cx| {
-        task.await
-            .map_err(|_| SharedString::from("Failed to write API key to keychain"))?;
-        cx.update(|cx| {
-            update_settings_file(fs, cx, move |settings, _cx| {
-                let language_models = settings.language_models.get_or_insert_default();
-                match models {
-                    ParsedModels::OpenAi(available_models) => {
-                        language_models
-                            .openai_compatible
-                            .get_or_insert_default()
-                            .insert(
-                                provider_name,
-                                OpenAiCompatibleSettingsContent {
-                                    api_url,
-                                    available_models,
-                                    custom_headers: None,
-                                },
-                            );
-                    }
-                    ParsedModels::Anthropic(available_models) => {
-                        language_models
-                            .anthropic_compatible
-                            .get_or_insert_default()
-                            .insert(
-                                provider_name,
-                                AnthropicCompatibleSettingsContent {
-                                    api_url,
-                                    available_models,
-                                    custom_headers: None,
-                                },
-                            );
-                    }
-                }
-            });
-        });
-        Ok(())
-    })
-}
-
-pub struct AddLlmProviderModal {
-    provider: LlmCompatibleProvider,
-    input: AddLlmProviderInput,
-    scroll_handle: ScrollHandle,
-    focus_handle: FocusHandle,
-    last_error: Option,
-}
-
-impl AddLlmProviderModal {
-    pub fn toggle(
-        provider: LlmCompatibleProvider,
-        workspace: &mut Workspace,
-        window: &mut Window,
-        cx: &mut Context,
-    ) {
-        workspace.toggle_modal(window, cx, |window, cx| Self::new(provider, window, cx));
-    }
-
-    fn new(provider: LlmCompatibleProvider, window: &mut Window, cx: &mut Context) -> Self {
-        Self {
-            input: AddLlmProviderInput::new(provider, window, cx),
-            provider,
-            last_error: None,
-            focus_handle: cx.focus_handle(),
-            scroll_handle: ScrollHandle::new(),
-        }
-    }
-
-    fn confirm(&mut self, _: &menu::Confirm, _: &mut Window, cx: &mut Context) {
-        let task = save_provider_to_settings(self.provider, &self.input, cx);
-        cx.spawn(async move |this, cx| {
-            let result = task.await;
-            this.update(cx, |this, cx| match result {
-                Ok(_) => {
-                    cx.emit(DismissEvent);
-                }
-                Err(error) => {
-                    this.last_error = Some(error);
-                    cx.notify();
-                }
-            })
-        })
-        .detach_and_log_err(cx);
-    }
-
-    fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) {
-        cx.emit(DismissEvent);
-    }
-
-    fn render_model_section(
-        &self,
-        window: &mut Window,
-        cx: &mut Context,
-    ) -> impl IntoElement {
-        v_flex()
-            .mt_1()
-            .gap_2()
-            .child(
-                h_flex()
-                    .justify_between()
-                    .child(Label::new("Models").size(LabelSize::Small))
-                    .child(
-                        Button::new("add-model", "Add Model")
-                            .start_icon(
-                                Icon::new(IconName::Plus)
-                                    .size(IconSize::XSmall)
-                                    .color(Color::Muted),
-                            )
-                            .label_size(LabelSize::Small)
-                            .on_click(cx.listener(|this, _, window, cx| {
-                                this.input.add_model(window, cx);
-                                cx.notify();
-                            })),
-                    ),
-            )
-            .children(
-                self.input
-                    .models
-                    .iter()
-                    .enumerate()
-                    .map(|(ix, _)| self.render_model(ix, window, cx)),
-            )
-    }
-
-    fn render_open_ai_reasoning_settings(
-        &self,
-        ix: usize,
-        window: &mut Window,
-        cx: &mut Context,
-    ) -> impl IntoElement + use<> {
-        let model = &self.input.models[ix];
-        let selected_effort = model.reasoning_effort;
-        let supports_thinking = model.capabilities.supports_thinking;
-        let supports_chat_completions = model.capabilities.supports_chat_completions;
-        let interleaved_reasoning = model.capabilities.interleaved_reasoning;
-        let weak_self = cx.weak_entity();
-
-        let effort_menu = ContextMenu::build(window, cx, move |mut menu, _window, _cx| {
-            for effort in OpenAiReasoningEffort::OPENAI_COMPATIBLE_SELECTABLE {
-                let is_selected = effort == selected_effort;
-                let weak_self = weak_self.clone();
-                menu.push_item(
-                    ContextMenuEntry::new(effort.label())
-                        .toggleable(IconPosition::End, is_selected)
-                        .handler(move |_window, cx| {
-                            weak_self
-                                .update(cx, |this, cx| {
-                                    this.input.models[ix].reasoning_effort = effort;
-                                    cx.notify();
-                                })
-                                .ok();
-                        }),
-                );
-            }
-
-            menu
-        });
-
-        v_flex()
-            .gap_1()
-            .child(
-                Checkbox::new(("supports-thinking", ix), supports_thinking)
-                    .label("Supports thinking")
-                    .on_click(cx.listener(move |this, checked, _window, cx| {
-                        this.input.models[ix].capabilities.supports_thinking = *checked;
-                        cx.notify();
-                    })),
-            )
-            .when(supports_thinking.selected(), |parent| {
-                parent
-                    .child(
-                        v_flex()
-                            .gap_1()
-                            .child(Label::new("Default reasoning effort").size(LabelSize::Small))
-                            .child(
-                                DropdownMenu::new(
-                                    ElementId::Name(
-                                        format!("reasoning-effort-selector-{ix}").into(),
-                                    ),
-                                    selected_effort.label(),
-                                    effort_menu,
-                                )
-                                .style(DropdownStyle::Outlined)
-                                .trigger_size(ButtonSize::Compact)
-                                .full_width(true)
-                                .aria_label("Default reasoning effort"),
-                            ),
-                    )
-                    .when(supports_chat_completions.selected(), |parent| {
-                        parent.child(
-                            Checkbox::new(("interleaved-reasoning", ix), interleaved_reasoning)
-                                .label("Preserves thinking in chat history")
-                                .on_click(cx.listener(move |this, checked, _window, cx| {
-                                    this.input.models[ix].capabilities.interleaved_reasoning =
-                                        *checked;
-                                    cx.notify();
-                                })),
-                        )
-                    })
-            })
-    }
-
-    fn render_model(
-        &self,
-        ix: usize,
-        window: &mut Window,
-        cx: &mut Context,
-    ) -> impl IntoElement + use<> {
-        let has_more_than_one_model = self.input.models.len() > 1;
-        let is_open_ai = self.provider.is_open_ai();
-        let model = &self.input.models[ix];
-
-        v_flex()
-            .p_2()
-            .gap_2()
-            .rounded_sm()
-            .border_1()
-            .border_dashed()
-            .border_color(cx.theme().colors().border.opacity(0.6))
-            .bg(cx.theme().colors().element_active.opacity(0.15))
-            .child(model.name.clone())
-            .child(
-                h_flex()
-                    .gap_2()
-                    .when(is_open_ai, |parent| {
-                        parent.child(model.max_completion_tokens.clone())
-                    })
-                    .child(model.max_output_tokens.clone()),
-            )
-            .child(model.max_tokens.clone())
-            .child(
-                v_flex()
-                    .gap_1()
-                    .child(
-                        Checkbox::new(("supports-tools", ix), model.capabilities.supports_tools)
-                            .label("Supports tools")
-                            .on_click(cx.listener(move |this, checked, _window, cx| {
-                                this.input.models[ix].capabilities.supports_tools = *checked;
-                                cx.notify();
-                            })),
-                    )
-                    .child(
-                        Checkbox::new(("supports-images", ix), model.capabilities.supports_images)
-                            .label("Supports images")
-                            .on_click(cx.listener(move |this, checked, _window, cx| {
-                                this.input.models[ix].capabilities.supports_images = *checked;
-                                cx.notify();
-                            })),
-                    )
-                    .when(is_open_ai, |parent| {
-                        parent
-                            .child(
-                                Checkbox::new(
-                                    ("supports-parallel-tool-calls", ix),
-                                    model.capabilities.supports_parallel_tool_calls,
-                                )
-                                .label("Supports parallel_tool_calls")
-                                .on_click(cx.listener(
-                                    move |this, checked, _window, cx| {
-                                        this.input.models[ix]
-                                            .capabilities
-                                            .supports_parallel_tool_calls = *checked;
-                                        cx.notify();
-                                    },
-                                )),
-                            )
-                            .child(
-                                Checkbox::new(
-                                    ("supports-prompt-cache-key", ix),
-                                    model.capabilities.supports_prompt_cache_key,
-                                )
-                                .label("Supports prompt_cache_key")
-                                .on_click(cx.listener(
-                                    move |this, checked, _window, cx| {
-                                        this.input.models[ix]
-                                            .capabilities
-                                            .supports_prompt_cache_key = *checked;
-                                        cx.notify();
-                                    },
-                                )),
-                            )
-                            .child(
-                                Checkbox::new(
-                                    ("supports-chat-completions", ix),
-                                    model.capabilities.supports_chat_completions,
-                                )
-                                .label("Supports /chat/completions")
-                                .on_click(cx.listener(
-                                    move |this, checked, _window, cx| {
-                                        this.input.models[ix]
-                                            .capabilities
-                                            .supports_chat_completions = *checked;
-                                        cx.notify();
-                                    },
-                                )),
-                            )
-                            .when(
-                                model.capabilities.supports_chat_completions.selected(),
-                                |parent| {
-                                    parent.child(
-                                        Checkbox::new(
-                                            ("max-tokens-parameter", ix),
-                                            model.capabilities.max_tokens_parameter,
-                                        )
-                                        .label("Uses max_tokens for output limit")
-                                        .on_click(
-                                            cx.listener(move |this, checked, _window, cx| {
-                                                this.input.models[ix]
-                                                    .capabilities
-                                                    .max_tokens_parameter = *checked;
-                                                cx.notify();
-                                            }),
-                                        ),
-                                    )
-                                },
-                            )
-                            .child(self.render_open_ai_reasoning_settings(ix, window, cx))
-                    }),
-            )
-            .when(has_more_than_one_model, |this| {
-                this.child(
-                    Button::new(("remove-model", ix), "Remove Model")
-                        .start_icon(
-                            Icon::new(IconName::Trash)
-                                .size(IconSize::XSmall)
-                                .color(Color::Muted),
-                        )
-                        .label_size(LabelSize::Small)
-                        .style(ButtonStyle::Outlined)
-                        .full_width()
-                        .on_click(cx.listener(move |this, _, _window, cx| {
-                            this.input.remove_model(ix);
-                            cx.notify();
-                        })),
-                )
-            })
-    }
-
-    fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context) {
-        window.focus_next(cx);
-    }
-
-    fn on_tab_prev(
-        &mut self,
-        _: &menu::SelectPrevious,
-        window: &mut Window,
-        cx: &mut Context,
-    ) {
-        window.focus_prev(cx);
-    }
-}
-
-impl EventEmitter for AddLlmProviderModal {}
-
-impl Focusable for AddLlmProviderModal {
-    fn focus_handle(&self, _cx: &App) -> FocusHandle {
-        self.focus_handle.clone()
-    }
-}
-
-impl ModalView for AddLlmProviderModal {}
-
-impl Render for AddLlmProviderModal {
-    fn render(&mut self, window: &mut ui::Window, cx: &mut ui::Context) -> impl IntoElement {
-        let focus_handle = self.focus_handle(cx);
-
-        let window_size = window.viewport_size();
-        let rem_size = window.rem_size();
-        let is_large_window = window_size.height / rem_size > rems_from_px(600.).0;
-
-        let modal_max_height = if is_large_window {
-            rems_from_px(450.)
-        } else {
-            rems_from_px(200.)
-        };
-
-        v_flex()
-            .id("add-llm-provider-modal")
-            .key_context("AddLlmProviderModal")
-            .w(rems(34.))
-            .elevation_3(cx)
-            .on_action(cx.listener(Self::cancel))
-            .on_action(cx.listener(Self::on_tab))
-            .on_action(cx.listener(Self::on_tab_prev))
-            .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
-                this.focus_handle(cx).focus(window, cx);
-            }))
-            .child(
-                Modal::new("configure-context-server", None)
-                    .header(
-                        ModalHeader::new()
-                            .headline("Add LLM Provider")
-                            .description(self.provider.description()),
-                    )
-                    .when_some(self.last_error.clone(), |this, error| {
-                        this.section(
-                            Section::new().child(
-                                Banner::new()
-                                    .severity(Severity::Warning)
-                                    .child(div().text_xs().child(error)),
-                            ),
-                        )
-                    })
-                    .child(
-                        div()
-                            .size_full()
-                            .vertical_scrollbar_for(&self.scroll_handle, window, cx)
-                            .child(
-                                v_flex()
-                                    .id("modal_content")
-                                    .size_full()
-                                    .tab_group()
-                                    .max_h(modal_max_height)
-                                    .pl_3()
-                                    .pr_4()
-                                    .pb_2()
-                                    .gap_2()
-                                    .overflow_y_scroll()
-                                    .track_scroll(&self.scroll_handle)
-                                    .child(self.input.provider_name.clone())
-                                    .child(self.input.api_url.clone())
-                                    .child(self.input.api_key.clone())
-                                    .child(self.render_model_section(window, cx)),
-                            ),
-                    )
-                    .footer(
-                        ModalFooter::new().end_slot(
-                            h_flex()
-                                .gap_1()
-                                .child(
-                                    Button::new("cancel", "Cancel")
-                                        .key_binding(
-                                            KeyBinding::for_action_in(
-                                                &menu::Cancel,
-                                                &focus_handle,
-                                                cx,
-                                            )
-                                            .map(|kb| kb.size(rems_from_px(12.))),
-                                        )
-                                        .on_click(cx.listener(|this, _event, window, cx| {
-                                            this.cancel(&menu::Cancel, window, cx)
-                                        })),
-                                )
-                                .child(
-                                    Button::new("save-server", "Save Provider")
-                                        .key_binding(
-                                            KeyBinding::for_action_in(
-                                                &menu::Confirm,
-                                                &focus_handle,
-                                                cx,
-                                            )
-                                            .map(|kb| kb.size(rems_from_px(12.))),
-                                        )
-                                        .on_click(cx.listener(|this, _event, window, cx| {
-                                            this.confirm(&menu::Confirm, window, cx)
-                                        })),
-                                ),
-                        ),
-                    ),
-            )
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use fs::FakeFs;
-    use gpui::{TestAppContext, VisualTestContext};
-    use language_model::{
-        LanguageModelProviderId, LanguageModelProviderName,
-        fake_provider::FakeLanguageModelProvider,
-    };
-    use project::Project;
-    use settings::SettingsStore;
-    use util::path;
-    use workspace::MultiWorkspace;
-
-    #[gpui::test]
-    async fn test_save_provider_invalid_inputs(cx: &mut TestAppContext) {
-        let cx = setup_test(cx).await;
-
-        for provider in [
-            LlmCompatibleProvider::OpenAi,
-            LlmCompatibleProvider::Anthropic,
-        ] {
-            assert_eq!(
-                save_provider_validation_errors(provider, "", "someurl", "somekey", vec![], cx)
-                    .await,
-                Some("Provider Name cannot be empty".into())
-            );
-
-            assert_eq!(
-                save_provider_validation_errors(
-                    provider,
-                    "someprovider",
-                    "",
-                    "somekey",
-                    vec![],
-                    cx
-                )
-                .await,
-                Some("API URL cannot be empty".into())
-            );
-
-            assert_eq!(
-                save_provider_validation_errors(
-                    provider,
-                    "someprovider",
-                    "someurl",
-                    "",
-                    vec![],
-                    cx
-                )
-                .await,
-                Some("API Key cannot be empty".into())
-            );
-
-            assert_eq!(
-                save_provider_validation_errors(
-                    provider,
-                    "someprovider",
-                    "someurl",
-                    "somekey",
-                    vec![("", "200000", "200000", "32000")],
-                    cx,
-                )
-                .await,
-                Some("Model Name cannot be empty".into())
-            );
-
-            assert_eq!(
-                save_provider_validation_errors(
-                    provider,
-                    "someprovider",
-                    "someurl",
-                    "somekey",
-                    vec![("somemodel", "abc", "200000", "32000")],
-                    cx,
-                )
-                .await,
-                Some("Max Tokens must be a number".into())
-            );
-
-            assert_eq!(
-                save_provider_validation_errors(
-                    provider,
-                    "someprovider",
-                    "someurl",
-                    "somekey",
-                    vec![("somemodel", "200000", "200000", "abc")],
-                    cx,
-                )
-                .await,
-                Some("Max Output Tokens must be a number".into())
-            );
-
-            assert_eq!(
-                save_provider_validation_errors(
-                    provider,
-                    "someprovider",
-                    "someurl",
-                    "somekey",
-                    vec![
-                        ("somemodel", "200000", "200000", "32000"),
-                        ("somemodel", "200000", "200000", "32000"),
-                    ],
-                    cx,
-                )
-                .await,
-                Some("Model Names must be unique".into())
-            );
-        }
-
-        // Max Completion Tokens is only used by OpenAI-compatible providers.
-        assert_eq!(
-            save_provider_validation_errors(
-                LlmCompatibleProvider::OpenAi,
-                "someprovider",
-                "someurl",
-                "somekey",
-                vec![("somemodel", "200000", "abc", "32000")],
-                cx,
-            )
-            .await,
-            Some("Max Completion Tokens must be a number".into())
-        );
-    }
-
-    #[gpui::test]
-    async fn test_save_provider_name_conflict(cx: &mut TestAppContext) {
-        let cx = setup_test(cx).await;
-
-        cx.update(|_window, cx| {
-            LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
-                registry.register_provider(
-                    Arc::new(FakeLanguageModelProvider::new(
-                        LanguageModelProviderId::new("someprovider"),
-                        LanguageModelProviderName::new("Some Provider"),
-                    )),
-                    cx,
-                );
-            });
-        });
-
-        assert_eq!(
-            save_provider_validation_errors(
-                LlmCompatibleProvider::OpenAi,
-                "someprovider",
-                "someurl",
-                "someapikey",
-                vec![("somemodel", "200000", "200000", "32000")],
-                cx,
-            )
-            .await,
-            Some("Provider Name is already taken by another provider".into())
-        );
-    }
-
-    #[gpui::test]
-    async fn test_model_input_default_capabilities(cx: &mut TestAppContext) {
-        let cx = setup_test(cx).await;
-
-        cx.update(|window, cx| {
-            let model_input = ModelInput::new(0, window, cx);
-            model_input.name.update(cx, |input, cx| {
-                input.set_text("somemodel", window, cx);
-            });
-            assert_eq!(
-                model_input.capabilities.supports_tools,
-                ToggleState::Selected
-            );
-            assert_eq!(
-                model_input.capabilities.supports_images,
-                ToggleState::Unselected
-            );
-            assert_eq!(
-                model_input.capabilities.supports_parallel_tool_calls,
-                ToggleState::Unselected
-            );
-            assert_eq!(
-                model_input.capabilities.supports_prompt_cache_key,
-                ToggleState::Unselected
-            );
-            assert_eq!(
-                model_input.capabilities.supports_chat_completions,
-                ToggleState::Selected
-            );
-            assert_eq!(
-                model_input.capabilities.supports_thinking,
-                ToggleState::Unselected
-            );
-            assert_eq!(
-                model_input.capabilities.interleaved_reasoning,
-                ToggleState::Unselected
-            );
-            assert_eq!(
-                model_input.capabilities.max_tokens_parameter,
-                ToggleState::Unselected
-            );
-            assert_eq!(model_input.reasoning_effort, OpenAiReasoningEffort::Medium);
-
-            let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap();
-            assert!(parsed_model.capabilities.tools);
-            assert!(!parsed_model.capabilities.images);
-            assert!(!parsed_model.capabilities.parallel_tool_calls);
-            assert!(!parsed_model.capabilities.prompt_cache_key);
-            assert!(parsed_model.capabilities.chat_completions);
-            assert!(!parsed_model.capabilities.interleaved_reasoning);
-            assert!(!parsed_model.capabilities.max_tokens_parameter);
-            assert_eq!(parsed_model.reasoning_effort, None);
-        });
-    }
-
-    #[gpui::test]
-    async fn test_model_input_deselected_capabilities(cx: &mut TestAppContext) {
-        let cx = setup_test(cx).await;
-
-        cx.update(|window, cx| {
-            let mut model_input = ModelInput::new(0, window, cx);
-            model_input.name.update(cx, |input, cx| {
-                input.set_text("somemodel", window, cx);
-            });
-
-            model_input.capabilities.supports_tools = ToggleState::Unselected;
-            model_input.capabilities.supports_images = ToggleState::Unselected;
-            model_input.capabilities.supports_parallel_tool_calls = ToggleState::Unselected;
-            model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected;
-            model_input.capabilities.supports_chat_completions = ToggleState::Unselected;
-            model_input.capabilities.supports_thinking = ToggleState::Unselected;
-            model_input.capabilities.interleaved_reasoning = ToggleState::Selected;
-            model_input.capabilities.max_tokens_parameter = ToggleState::Selected;
-
-            let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap();
-            assert!(!parsed_model.capabilities.tools);
-            assert!(!parsed_model.capabilities.images);
-            assert!(!parsed_model.capabilities.parallel_tool_calls);
-            assert!(!parsed_model.capabilities.prompt_cache_key);
-            assert!(!parsed_model.capabilities.chat_completions);
-            assert!(!parsed_model.capabilities.interleaved_reasoning);
-            assert!(!parsed_model.capabilities.max_tokens_parameter);
-            assert_eq!(parsed_model.reasoning_effort, None);
-        });
-    }
-
-    #[gpui::test]
-    async fn test_model_input_with_name_and_capabilities(cx: &mut TestAppContext) {
-        let cx = setup_test(cx).await;
-
-        cx.update(|window, cx| {
-            let mut model_input = ModelInput::new(0, window, cx);
-            model_input.name.update(cx, |input, cx| {
-                input.set_text("somemodel", window, cx);
-            });
-
-            model_input.capabilities.supports_tools = ToggleState::Selected;
-            model_input.capabilities.supports_images = ToggleState::Unselected;
-            model_input.capabilities.supports_parallel_tool_calls = ToggleState::Selected;
-            model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected;
-            model_input.capabilities.supports_chat_completions = ToggleState::Selected;
-            model_input.capabilities.supports_thinking = ToggleState::Selected;
-            model_input.capabilities.interleaved_reasoning = ToggleState::Selected;
-            model_input.capabilities.max_tokens_parameter = ToggleState::Selected;
-            model_input.reasoning_effort = OpenAiReasoningEffort::XHigh;
-
-            let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap();
-            assert_eq!(parsed_model.name, "somemodel");
-            assert!(parsed_model.capabilities.tools);
-            assert!(!parsed_model.capabilities.images);
-            assert!(parsed_model.capabilities.parallel_tool_calls);
-            assert!(!parsed_model.capabilities.prompt_cache_key);
-            assert!(parsed_model.capabilities.chat_completions);
-            assert!(parsed_model.capabilities.interleaved_reasoning);
-            assert!(parsed_model.capabilities.max_tokens_parameter);
-            assert_eq!(
-                parsed_model.reasoning_effort,
-                Some(OpenAiReasoningEffort::XHigh)
-            );
-        });
-    }
-
-    #[gpui::test]
-    async fn test_model_input_parse_anthropic_compatible(cx: &mut TestAppContext) {
-        let cx = setup_test(cx).await;
-
-        cx.update(|window, cx| {
-            let mut model_input = ModelInput::new(0, window, cx);
-            model_input.name.update(cx, |input, cx| {
-                input.set_text("somemodel", window, cx);
-            });
-
-            let parsed_model = model_input.parse_anthropic_compatible(cx).unwrap();
-            assert_eq!(parsed_model.name, "somemodel");
-            assert_eq!(parsed_model.max_tokens, 200000);
-            assert_eq!(parsed_model.max_output_tokens, Some(32000));
-            assert!(parsed_model.capabilities.tools);
-            assert!(!parsed_model.capabilities.images);
-
-            model_input.capabilities.supports_tools = ToggleState::Unselected;
-            model_input.capabilities.supports_images = ToggleState::Selected;
-
-            let parsed_model = model_input.parse_anthropic_compatible(cx).unwrap();
-            assert!(!parsed_model.capabilities.tools);
-            assert!(parsed_model.capabilities.images);
-        });
-    }
-
-    async fn setup_test(cx: &mut TestAppContext) -> &mut VisualTestContext {
-        cx.update(|cx| {
-            let store = SettingsStore::test(cx);
-            cx.set_global(store);
-            theme_settings::init(theme::LoadThemes::JustBase, cx);
-
-            language_model::init(cx);
-            editor::init(cx);
-        });
-
-        let fs = FakeFs::new(cx.executor());
-        cx.update(|cx| ::set_global(fs.clone(), cx));
-        let project = Project::test(fs, [path!("/dir").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());
-
-        cx
-    }
-
-    async fn save_provider_validation_errors(
-        provider: LlmCompatibleProvider,
-        provider_name: &str,
-        api_url: &str,
-        api_key: &str,
-        models: Vec<(&str, &str, &str, &str)>,
-        cx: &mut VisualTestContext,
-    ) -> Option {
-        fn set_text(input: &Entity, text: &str, window: &mut Window, cx: &mut App) {
-            input.update(cx, |input, cx| {
-                input.set_text(text, window, cx);
-            });
-        }
-
-        let task = cx.update(|window, cx| {
-            let mut input = AddLlmProviderInput::new(provider, window, cx);
-            set_text(&input.provider_name, provider_name, window, cx);
-            set_text(&input.api_url, api_url, window, cx);
-            set_text(&input.api_key, api_key, window, cx);
-
-            for (i, (name, max_tokens, max_completion_tokens, max_output_tokens)) in
-                models.iter().enumerate()
-            {
-                if i >= input.models.len() {
-                    input.models.push(ModelInput::new(i, window, cx));
-                }
-                let model = &mut input.models[i];
-                set_text(&model.name, name, window, cx);
-                set_text(&model.max_tokens, max_tokens, window, cx);
-                set_text(
-                    &model.max_completion_tokens,
-                    max_completion_tokens,
-                    window,
-                    cx,
-                );
-                set_text(&model.max_output_tokens, max_output_tokens, window, cx);
-            }
-            save_provider_to_settings(provider, &input, cx)
-        });
-
-        task.await.err()
-    }
-}
diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs
index 5ceb13d594c84e..843a35068be21d 100644
--- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs
+++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs
@@ -3,6 +3,7 @@ use collections::HashMap;
 use context_server::{ContextServerCommand, ContextServerId};
 use editor::{Editor, EditorElement, EditorStyle};
 
+use extension_host::ExtensionStore;
 use gpui::{
     AsyncWindowContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle,
     Subscription, Task, TaskExt, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity,
@@ -402,7 +403,12 @@ fn resolve_context_server_extension(
         return Task::ready(None);
     };
 
-    let extension = crate::agent_configuration::resolve_extension_for_context_server(&id, cx);
+    let extension = ExtensionStore::global(cx)
+        .read(cx)
+        .installed_extensions()
+        .iter()
+        .find(|(_, entry)| entry.manifest.context_servers.contains_key(&id.0))
+        .map(|(id, entry)| (id.clone(), entry.manifest.clone()));
     cx.spawn(async move |cx| {
         let installation = descriptor
             .configuration(worktree_store, cx)
diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs
deleted file mode 100644
index 5115e2f70c0ae8..00000000000000
--- a/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs
+++ /dev/null
@@ -1,175 +0,0 @@
-use agent::ContextServerRegistry;
-use collections::HashMap;
-use context_server::ContextServerId;
-use gpui::{
-    DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle, Window, prelude::*,
-};
-use ui::{Divider, DividerColor, Modal, ModalHeader, WithScrollbar, prelude::*};
-use workspace::{ModalView, Workspace};
-
-pub struct ConfigureContextServerToolsModal {
-    context_server_id: ContextServerId,
-    context_server_registry: Entity,
-    focus_handle: FocusHandle,
-    expanded_tools: HashMap,
-    scroll_handle: ScrollHandle,
-}
-
-impl ConfigureContextServerToolsModal {
-    fn new(
-        context_server_id: ContextServerId,
-        context_server_registry: Entity,
-        _window: &mut Window,
-        cx: &mut Context,
-    ) -> Self {
-        Self {
-            context_server_id,
-            context_server_registry,
-            focus_handle: cx.focus_handle(),
-            expanded_tools: HashMap::default(),
-            scroll_handle: ScrollHandle::new(),
-        }
-    }
-
-    pub fn toggle(
-        context_server_id: ContextServerId,
-        context_server_registry: Entity,
-        workspace: &mut Workspace,
-        window: &mut Window,
-        cx: &mut Context,
-    ) {
-        workspace.toggle_modal(window, cx, |window, cx| {
-            Self::new(context_server_id, context_server_registry, window, cx)
-        });
-    }
-
-    fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) {
-        cx.emit(DismissEvent)
-    }
-
-    fn render_modal_content(
-        &self,
-        window: &mut Window,
-        cx: &mut Context,
-    ) -> impl IntoElement {
-        let tools = self
-            .context_server_registry
-            .read(cx)
-            .tools_for_server(&self.context_server_id)
-            .collect::>();
-
-        div()
-            .size_full()
-            .pb_2()
-            .child(
-                v_flex()
-                    .id("modal_content")
-                    .px_2()
-                    .gap_1()
-                    .max_h_128()
-                    .overflow_y_scroll()
-                    .track_scroll(&self.scroll_handle)
-                    .children(tools.iter().enumerate().flat_map(|(index, tool)| {
-                        let tool_name = tool.name();
-                        let is_expanded = self
-                            .expanded_tools
-                            .get(tool_name.as_ref())
-                            .copied()
-                            .unwrap_or(false);
-
-                        let icon = if is_expanded {
-                            IconName::ChevronUp
-                        } else {
-                            IconName::ChevronDown
-                        };
-
-                        let mut items = vec![
-                            v_flex()
-                                .child(
-                                    h_flex()
-                                        .id(format!("tool-header-{}", index))
-                                        .py_1()
-                                        .pl_1()
-                                        .pr_2()
-                                        .w_full()
-                                        .justify_between()
-                                        .rounded_sm()
-                                        .hover(|s| s.bg(cx.theme().colors().element_hover))
-                                        .child(
-                                            Label::new(tool_name.clone())
-                                                .buffer_font(cx)
-                                                .size(LabelSize::Small),
-                                        )
-                                        .child(
-                                            Icon::new(icon)
-                                                .size(IconSize::Small)
-                                                .color(Color::Muted),
-                                        )
-                                        .on_click(cx.listener({
-                                            move |this, _event, _window, _cx| {
-                                                let current = this
-                                                    .expanded_tools
-                                                    .get(tool_name.as_ref())
-                                                    .copied()
-                                                    .unwrap_or(false);
-                                                this.expanded_tools
-                                                    .insert(tool_name.clone(), !current);
-                                                _cx.notify();
-                                            }
-                                        })),
-                                )
-                                .when(is_expanded, |this| {
-                                    this.child(
-                                        Label::new(tool.description()).color(Color::Muted).mx_1(),
-                                    )
-                                })
-                                .into_any_element(),
-                        ];
-
-                        if index < tools.len() - 1 {
-                            items.push(
-                                h_flex()
-                                    .w_full()
-                                    .child(Divider::horizontal().color(DividerColor::BorderVariant))
-                                    .into_any_element(),
-                            );
-                        }
-
-                        items
-                    })),
-            )
-            .vertical_scrollbar_for(&self.scroll_handle, window, cx)
-            .into_any_element()
-    }
-}
-
-impl ModalView for ConfigureContextServerToolsModal {}
-
-impl Focusable for ConfigureContextServerToolsModal {
-    fn focus_handle(&self, _cx: &App) -> FocusHandle {
-        self.focus_handle.clone()
-    }
-}
-
-impl EventEmitter for ConfigureContextServerToolsModal {}
-
-impl Render for ConfigureContextServerToolsModal {
-    fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
-        div()
-            .key_context("ContextServerToolsModal")
-            .occlude()
-            .elevation_3(cx)
-            .w(rems(34.))
-            .on_action(cx.listener(Self::cancel))
-            .track_focus(&self.focus_handle)
-            .child(
-                Modal::new("configure-context-server-tools", None::)
-                    .header(
-                        ModalHeader::new()
-                            .headline(format!("Tools from {}", self.context_server_id.0))
-                            .show_dismiss_button(true),
-                    )
-                    .child(self.render_modal_content(window, cx)),
-            )
-    }
-}
diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs
index 6d608958e65ab1..01b380044acfee 100644
--- a/crates/agent_ui/src/agent_panel.rs
+++ b/crates/agent_ui/src/agent_panel.rs
@@ -20,7 +20,6 @@ use db::kvp::{Dismissable, KeyValueStore};
 use itertools::Itertools;
 use project::{AgentId, ProjectItem};
 use serde::{Deserialize, Serialize};
-use settings::{LanguageModelProviderSetting, LanguageModelSelection};
 
 use zed_actions::{
     DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize,
@@ -49,7 +48,6 @@ use crate::{
     LoadThreadFromClipboard, NewTerminalThread, NewThread, OpenActiveThreadAsMarkdown,
     OpenAgentDiff, ResetFastModeWarnings, ResetTrialEndUpsell, ResetTrialUpsell,
     ShowAllSidebarThreadMetadata, ShowThreadMetadata, ToggleNewThreadMenu, ToggleOptionsMenu,
-    agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
     conversation_view::{
         AcpThreadViewEvent, RootThreadUpdated, ThreadView, reset_fast_mode_warnings,
     },
@@ -70,9 +68,7 @@ use cloud_api_types::Plan;
 use collections::HashMap;
 use editor::{Editor, MultiBuffer};
 use extension_host::ExtensionStore;
-use feature_flags::{
-    AgentSettingsUiFeatureFlag, CreateThreadToolFeatureFlag, FeatureFlagAppExt as _,
-};
+use feature_flags::{CreateThreadToolFeatureFlag, FeatureFlagAppExt as _};
 
 use fs::Fs;
 use futures::FutureExt as _;
@@ -1124,15 +1120,10 @@ impl From for BaseView {
     }
 }
 
-enum OverlayView {
-    Configuration,
-}
-
 enum VisibleSurface<'a> {
     Uninitialized,
     AgentThread(&'a Entity),
     Terminal(&'a Entity),
-    Configuration(Option<&'a Entity>),
 }
 
 enum WhichFontSize {
@@ -1149,14 +1140,6 @@ impl BaseView {
     }
 }
 
-impl OverlayView {
-    pub fn which_font_size_used(&self) -> WhichFontSize {
-        match self {
-            OverlayView::Configuration => WhichFontSize::None,
-        }
-    }
-}
-
 pub struct AgentPanel {
     workspace: WeakEntity,
     /// Workspace id is used as a database key
@@ -1168,12 +1151,9 @@ pub struct AgentPanel {
     thread_store: Entity,
     connection_store: Entity,
     context_server_registry: Entity,
-    configuration: Option>,
-    configuration_subscription: Option,
     focus_handle: FocusHandle,
     base_view: BaseView,
     last_created_entry_kind: AgentPanelEntryKind,
-    overlay_view: Option,
     draft_thread: Option>,
     retained_threads: HashMap>,
     terminals: HashMap,
@@ -1568,15 +1548,12 @@ impl AgentPanel {
             workspace_id,
             base_view,
             last_created_entry_kind: AgentPanelEntryKind::Thread,
-            overlay_view: None,
             workspace,
             user_store,
             project: project.clone(),
             fs: fs.clone(),
             language_registry,
             connection_store,
-            configuration: None,
-            configuration_subscription: None,
             focus_handle: cx.focus_handle(),
             context_server_registry,
             draft_thread: None,
@@ -1752,7 +1729,6 @@ impl AgentPanel {
     pub fn clear_base_view(&mut self, window: &mut Window, cx: &mut Context) {
         let old_view = std::mem::replace(&mut self.base_view, BaseView::Uninitialized);
         self.retain_running_thread(old_view, cx);
-        self.clear_overlay_state();
         self.activate_draft(false, AgentThreadSource::AgentPanel, window, cx);
         self.serialize(cx);
         cx.emit(AgentPanelEvent::ActiveViewChanged);
@@ -2928,15 +2904,7 @@ impl AgentPanel {
         let draft = self.ensure_draft(source, window, cx);
         if let BaseView::AgentThread { conversation_view } = &self.base_view {
             if conversation_view.entity_id() == draft.entity_id() {
-                // If we're already viewing the draft as the base view but an
-                // overlay (e.g. Settings) is covering it, clear the overlay
-                // so the user actually sees the draft they asked for.
-                // Otherwise pressing "New Thread" from the Settings panel is
-                // a silent no-op because the early return below would leave
-                // the overlay on top of the draft.
-                if self.overlay_view.is_some() {
-                    self.clear_overlay(focus, window, cx);
-                } else if focus {
+                if focus {
                     self.focus_handle(cx).focus(window, cx);
                 }
                 return;
@@ -3302,7 +3270,6 @@ impl AgentPanel {
         }
 
         if self.active_thread_id(cx) == Some(id) {
-            self.clear_overlay_state();
             if activate_draft_after_remove {
                 self.activate_draft(false, AgentThreadSource::AgentPanel, window, cx);
             } else {
@@ -3547,13 +3514,6 @@ impl AgentPanel {
         })
     }
 
-    pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context) {
-        if self.overlay_view.is_some() {
-            self.clear_overlay(true, window, cx);
-            cx.notify();
-        }
-    }
-
     pub fn toggle_options_menu(
         &mut self,
         _: &ToggleOptionsMenu,
@@ -3671,57 +3631,13 @@ impl AgentPanel {
     }
 
     pub(crate) fn open_configuration(&mut self, window: &mut Window, cx: &mut Context) {
-        // When the agent settings have been moved into the settings UI, the
-        // panel no longer shows its own configuration overlay. Instead, route to
-        // the settings UI at the LLM providers page (where the model selector's
-        // "Configure" button expects to land).
-        if cx.has_flag::() {
-            window.dispatch_action(
-                Box::new(zed_actions::OpenSettingsAt {
-                    path: "llm_providers".to_string(),
-                    target: None,
-                }),
-                cx,
-            );
-            return;
-        }
-
-        if matches!(self.overlay_view, Some(OverlayView::Configuration)) {
-            self.clear_overlay(true, window, cx);
-            return;
-        }
-
-        let agent_server_store = self.project.read(cx).agent_server_store().clone();
-        let context_server_store = self.project.read(cx).context_server_store();
-        let fs = self.fs.clone();
-
-        self.configuration = Some(cx.new(|cx| {
-            AgentConfiguration::new(
-                fs,
-                agent_server_store,
-                self.connection_store.clone(),
-                context_server_store,
-                self.context_server_registry.clone(),
-                self.language_registry.clone(),
-                self.workspace.clone(),
-                window,
-                cx,
-            )
-        }));
-
-        if let Some(configuration) = self.configuration.as_ref() {
-            self.configuration_subscription = Some(cx.subscribe_in(
-                configuration,
-                window,
-                Self::handle_agent_configuration_event,
-            ));
-        }
-
-        self.set_overlay(OverlayView::Configuration, true, window, cx);
-
-        if let Some(configuration) = self.configuration.as_ref() {
-            configuration.focus_handle(cx).focus(window, cx);
-        }
+        window.dispatch_action(
+            Box::new(zed_actions::OpenSettingsPage {
+                page: "AI".to_string(),
+                target: None,
+            }),
+            cx,
+        );
     }
 
     pub(crate) fn open_active_thread_as_markdown(
@@ -4004,53 +3920,6 @@ impl AgentPanel {
             .detach_and_log_err(cx);
     }
 
-    fn handle_agent_configuration_event(
-        &mut self,
-        _entity: &Entity,
-        event: &AssistantConfigurationEvent,
-        window: &mut Window,
-        cx: &mut Context,
-    ) {
-        match event {
-            AssistantConfigurationEvent::NewThread(provider) => {
-                if LanguageModelRegistry::read_global(cx)
-                    .default_model()
-                    .is_none_or(|model| model.provider.id() != provider.id())
-                    && let Some(model) = provider.default_model(cx)
-                {
-                    update_settings_file(self.fs.clone(), cx, move |settings, _| {
-                        let provider = model.provider_id().0.to_string();
-                        let enable_thinking = model.supports_thinking();
-                        let effort = model
-                            .default_effort_level()
-                            .map(|effort| effort.value.to_string());
-                        let model = model.id().0.to_string();
-                        settings
-                            .agent
-                            .get_or_insert_default()
-                            .set_model(LanguageModelSelection {
-                                provider: LanguageModelProviderSetting(provider),
-                                model,
-                                enable_thinking,
-                                effort,
-                                speed: None,
-                            })
-                    });
-                }
-
-                self.activate_new_thread(true, AgentThreadSource::AgentPanel, window, cx);
-                if let Some((thread, model)) = self
-                    .active_native_agent_thread(cx)
-                    .zip(provider.default_model(cx))
-                {
-                    thread.update(cx, |thread, cx| {
-                        thread.set_model(model, cx);
-                    });
-                }
-            }
-        }
-    }
-
     pub fn workspace_id(&self) -> Option {
         self.workspace_id
     }
@@ -4283,8 +4152,6 @@ impl AgentPanel {
         window: &mut Window,
         cx: &mut Context,
     ) {
-        self.clear_overlay_state();
-
         let old_view = std::mem::replace(&mut self.base_view, new_view);
         self.retain_running_thread(old_view, cx);
 
@@ -4305,35 +4172,6 @@ impl AgentPanel {
         cx.emit(AgentPanelEvent::ActiveViewChanged);
     }
 
-    fn set_overlay(
-        &mut self,
-        overlay: OverlayView,
-        focus: bool,
-        window: &mut Window,
-        cx: &mut Context,
-    ) {
-        self.overlay_view = Some(overlay);
-        if focus {
-            self.focus_handle(cx).focus(window, cx);
-        }
-        cx.emit(AgentPanelEvent::ActiveViewChanged);
-    }
-
-    fn clear_overlay(&mut self, focus: bool, window: &mut Window, cx: &mut Context) {
-        self.clear_overlay_state();
-
-        if focus {
-            self.focus_handle(cx).focus(window, cx);
-        }
-        cx.emit(AgentPanelEvent::ActiveViewChanged);
-    }
-
-    fn clear_overlay_state(&mut self) {
-        self.overlay_view = None;
-        self.configuration_subscription = None;
-        self.configuration = None;
-    }
-
     fn refresh_base_view_subscriptions(&mut self, window: &mut Window, cx: &mut Context) {
         self._base_view_observation = match &self.base_view {
             BaseView::AgentThread { conversation_view } => {
@@ -4386,14 +4224,6 @@ impl AgentPanel {
     }
 
     fn visible_surface(&self) -> VisibleSurface<'_> {
-        if let Some(overlay_view) = &self.overlay_view {
-            return match overlay_view {
-                OverlayView::Configuration => {
-                    VisibleSurface::Configuration(self.configuration.as_ref())
-                }
-            };
-        }
-
         match &self.base_view {
             BaseView::Uninitialized => VisibleSurface::Uninitialized,
             BaseView::AgentThread { conversation_view } => {
@@ -4407,15 +4237,8 @@ impl AgentPanel {
         }
     }
 
-    fn is_overlay_open(&self) -> bool {
-        self.overlay_view.is_some()
-    }
-
     fn visible_font_size(&self) -> WhichFontSize {
-        self.overlay_view.as_ref().map_or_else(
-            || self.base_view.which_font_size_used(),
-            OverlayView::which_font_size_used,
-        )
+        self.base_view.which_font_size_used()
     }
 
     fn subscribe_to_active_thread_view(
@@ -4499,7 +4322,6 @@ impl AgentPanel {
         // Check if the active view already holds this thread.
         if let BaseView::AgentThread { conversation_view } = &self.base_view {
             if conversation_view.read(cx).thread_id == thread_id {
-                self.clear_overlay_state();
                 cx.emit(AgentPanelEvent::ActiveViewChanged);
                 return;
             }
@@ -5052,13 +4874,6 @@ impl Focusable for AgentPanel {
             VisibleSurface::Uninitialized => self.focus_handle.clone(),
             VisibleSurface::AgentThread(conversation_view) => conversation_view.focus_handle(cx),
             VisibleSurface::Terminal(terminal_view) => terminal_view.focus_handle(cx),
-            VisibleSurface::Configuration(configuration) => {
-                if let Some(configuration) = configuration {
-                    configuration.focus_handle(cx)
-                } else {
-                    self.focus_handle.clone()
-                }
-            }
         }
     }
 }
@@ -5294,10 +5109,7 @@ impl AgentPanel {
     }
 
     fn destination_has_meaningful_state(&self, cx: &App) -> bool {
-        if self.overlay_view.is_some()
-            || !self.retained_threads.is_empty()
-            || !self.terminals.is_empty()
-        {
+        if !self.retained_threads.is_empty() || !self.terminals.is_empty() {
             return true;
         }
 
@@ -5578,9 +5390,7 @@ impl AgentPanel {
                     Label::new("Terminal").into_any_element()
                 }
             }
-            VisibleSurface::Configuration(_) => {
-                Label::new("Settings").truncate().into_any_element()
-            }
+
             VisibleSurface::Uninitialized => Label::new("Agent").truncate().into_any_element(),
         };
 
@@ -5750,8 +5560,7 @@ impl AgentPanel {
                         if !showing_terminal {
                             menu = menu
                                 .header("MCP Servers")
-                                .action("Add Custom Server…", Box::new(AddContextServer::local()))
-                                .action("Add Remote Server…", Box::new(AddContextServer::remote()))
+                                .action("Add Server…", Box::new(AddContextServer::local()))
                                 .action(
                                     "Install New Servers…",
                                     Box::new(zed_actions::Extensions {
@@ -5844,21 +5653,6 @@ impl AgentPanel {
             })
     }
 
-    fn render_toolbar_back_button(&self, cx: &mut Context) -> impl IntoElement {
-        let focus_handle = self.focus_handle(cx);
-
-        IconButton::new("go-back", IconName::ArrowLeft)
-            .icon_size(IconSize::Small)
-            .on_click(cx.listener(|this, _, window, cx| {
-                this.go_back(&workspace::GoBack, window, cx);
-            }))
-            .tooltip({
-                move |_window, cx| {
-                    Tooltip::for_action_in("Go Back", &workspace::GoBack, &focus_handle, cx)
-                }
-            })
-    }
-
     fn render_no_project_state(&self, cx: &mut Context) -> impl IntoElement {
         let focus_handle = self.focus_handle(cx);
 
@@ -6139,15 +5933,12 @@ impl AgentPanel {
         };
 
         enum ToolbarMode {
-            Overlay,
             Terminal,
             EmptyThread,
             ActiveThread,
         }
 
-        let mode = if self.is_overlay_open() {
-            ToolbarMode::Overlay
-        } else if matches!(self.base_view, BaseView::Terminal { .. }) {
+        let mode = if matches!(self.base_view, BaseView::Terminal { .. }) {
             ToolbarMode::Terminal
         } else if self.active_thread_has_messages(cx) {
             ToolbarMode::ActiveThread
@@ -6231,11 +6022,7 @@ impl AgentPanel {
                         .overflow_hidden()
                         .gap(DynamicSpacing::Base04.rems(cx))
                         .pl(DynamicSpacing::Base04.rems(cx))
-                        .child(if matches!(mode, ToolbarMode::Overlay) {
-                            self.render_toolbar_back_button(cx).into_any_element()
-                        } else {
-                            selected_agent.into_any_element()
-                        })
+                        .child(selected_agent.into_any_element())
                         .child(match empty_thread_title {
                             Some(title) => title,
                             None => self.render_title_view(window, cx),
@@ -6574,7 +6361,6 @@ impl Render for AgentPanel {
             }))
             .on_action(cx.listener(Self::open_active_thread_as_markdown))
             .on_action(cx.listener(Self::manage_skills))
-            .on_action(cx.listener(Self::go_back))
             .on_action(cx.listener(Self::toggle_options_menu))
             .on_action(cx.listener(Self::increase_font_size))
             .on_action(cx.listener(Self::decrease_font_size))
@@ -6607,9 +6393,6 @@ impl Render for AgentPanel {
                 VisibleSurface::Terminal(terminal_view) => parent
                     .child(terminal_view.clone())
                     .child(self.render_drag_target(cx)),
-                VisibleSurface::Configuration(configuration) => {
-                    parent.children(configuration.cloned())
-                }
             })
             .children(self.render_trial_end_upsell(window, cx));
 
@@ -9537,63 +9320,6 @@ mod tests {
         });
     }
 
-    #[gpui::test]
-    async fn test_new_thread_dismisses_settings_overlay(cx: &mut TestAppContext) {
-        let (panel, mut cx) = setup_panel(cx).await;
-
-        // Put the panel on its ephemeral new-draft view so the base view
-        // already contains the draft that `NewThread` would activate.
-        panel.update_in(&mut cx, |panel, window, cx| {
-            panel.activate_new_thread(true, AgentThreadSource::AgentPanel, window, cx);
-        });
-        cx.run_until_parked();
-
-        panel.read_with(&cx, |panel, cx| {
-            assert!(
-                panel.active_view_is_new_draft(cx),
-                "precondition: base view should be the ephemeral draft"
-            );
-            assert!(!panel.is_overlay_open());
-        });
-
-        // Simulate the Settings overlay being open on top of the draft.
-        // We don't go through `open_configuration` here because it would
-        // build provider configuration views, which call into
-        // `LanguageModelProvider::configuration_view` — unimplemented for
-        // the fake provider used in tests. The bug being exercised lives
-        // entirely in the overlay/base-view bookkeeping, so toggling the
-        // overlay flag directly is sufficient.
-        panel.update_in(&mut cx, |panel, window, cx| {
-            panel.set_overlay(OverlayView::Configuration, true, window, cx);
-        });
-        cx.run_until_parked();
-
-        panel.read_with(&cx, |panel, _cx| {
-            assert!(
-                panel.is_overlay_open(),
-                "precondition: Settings overlay should be open"
-            );
-        });
-
-        // Dispatching `NewThread` while Settings is open must dismiss the
-        // overlay so the user actually sees the new thread. Previously
-        // this was a silent no-op: `activate_draft` early-returned without
-        // clearing the overlay because the base view already held the
-        // draft.
-        panel.update_in(&mut cx, |panel, window, cx| {
-            panel.new_thread(&NewThread, window, cx);
-        });
-        cx.run_until_parked();
-
-        panel.read_with(&cx, |panel, cx| {
-            assert!(
-                !panel.is_overlay_open(),
-                "Settings overlay should be dismissed when invoking NewThread"
-            );
-            assert!(panel.active_view_is_new_draft(cx));
-        });
-    }
-
     #[gpui::test]
     async fn test_terminal_title_omits_placeholder_title(cx: &mut TestAppContext) {
         let (panel, mut cx) = setup_panel(cx).await;
@@ -10273,62 +9999,6 @@ mod tests {
         );
     }
 
-    #[gpui::test]
-    async fn test_terminal_bell_notifies_when_configuration_overlay_covers_terminal(
-        cx: &mut TestAppContext,
-    ) {
-        let (panel, mut cx) = setup_visible_panel(cx).await;
-        let terminal_id = panel
-            .update_in(&mut cx, |panel, window, cx| {
-                panel.insert_test_terminal("Claude", true, window, cx)
-            })
-            .expect("test terminal should be inserted");
-        cx.run_until_parked();
-
-        panel.update_in(&mut cx, |panel, window, cx| {
-            panel.set_overlay(OverlayView::Configuration, true, window, cx);
-        });
-        panel.update(&mut cx, |panel, cx| {
-            panel.emit_test_terminal_bell(terminal_id, cx);
-        });
-        cx.run_until_parked();
-
-        panel.read_with(&cx, |panel, cx| {
-            let terminal = panel
-                .terminals(cx)
-                .into_iter()
-                .find(|terminal| terminal.id == terminal_id)
-                .expect("terminal should remain in the panel");
-            assert!(terminal.has_notification);
-        });
-        cx.windows()
-            .iter()
-            .find_map(|window| window.downcast::())
-            .expect("covered terminal bell should show a notification");
-    }
-
-    #[gpui::test]
-    async fn test_thread_notification_shows_when_configuration_overlay_covers_thread(
-        cx: &mut TestAppContext,
-    ) {
-        let (panel, mut cx) = setup_visible_panel(cx).await;
-        let connection = StubAgentConnection::new();
-        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
-            acp::ContentChunk::new("Default response".into()),
-        )]);
-        open_thread_with_connection(&panel, connection, &mut cx);
-
-        panel.update_in(&mut cx, |panel, window, cx| {
-            panel.set_overlay(OverlayView::Configuration, true, window, cx);
-        });
-        send_message(&panel, &mut cx);
-
-        cx.windows()
-            .iter()
-            .find_map(|window| window.downcast::())
-            .expect("covered thread should show a notification");
-    }
-
     #[gpui::test]
     async fn test_terminal_bell_marks_without_popup_when_sidebar_open(cx: &mut TestAppContext) {
         let (panel, mut cx) = setup_visible_panel(cx).await;
diff --git a/crates/copilot_ui/src/sign_in.rs b/crates/copilot_ui/src/sign_in.rs
index 28f8b25e0f11d0..4eb73b6d82e759 100644
--- a/crates/copilot_ui/src/sign_in.rs
+++ b/crates/copilot_ui/src/sign_in.rs
@@ -466,6 +466,10 @@ pub struct ConfigurationView {
     copilot_status: Option,
     is_authenticated: Box bool + 'static>,
     edit_prediction: bool,
+    /// When `true`, renders a compact control suitable for an inline settings
+    /// row: no explanatory labels (those live in the row's left column) and
+    /// content-sized buttons instead of full-width ones.
+    compact: bool,
     _subscription: Option,
 }
 
@@ -487,6 +491,7 @@ impl ConfigurationView {
             copilot_status: copilot.as_ref().map(|copilot| copilot.0.read(cx).status()),
             is_authenticated: Box::new(is_authenticated),
             edit_prediction: matches!(mode, ConfigurationMode::EditPrediction),
+            compact: false,
             _subscription: copilot.as_ref().map(|copilot| {
                 cx.observe(&copilot.0, |this, model, cx| {
                     this.copilot_status = Some(model.read(cx).status());
@@ -495,6 +500,14 @@ impl ConfigurationView {
             }),
         }
     }
+
+    /// Renders the view compactly for an inline settings row (no labels,
+    /// content-sized buttons). The explanatory copy is expected to be shown
+    /// elsewhere (e.g. the row's left column).
+    pub fn compact(mut self) -> Self {
+        self.compact = true;
+        self
+    }
 }
 
 impl ConfigurationView {
@@ -536,34 +549,34 @@ impl ConfigurationView {
         edit_prediction: bool,
     ) -> impl IntoElement {
         Button::new("loading_button", label)
-            .full_width()
+            .map(|this| {
+                if edit_prediction || self.compact {
+                    this.size(ButtonSize::Medium)
+                } else {
+                    this.full_width()
+                }
+            })
             .disabled(true)
             .loading(true)
             .style(ButtonStyle::Outlined)
-            .when(edit_prediction, |this| this.size(ButtonSize::Medium))
     }
 
     fn render_sign_in_button(&self, edit_prediction: bool) -> impl IntoElement {
         let label = if edit_prediction {
             "Sign in to GitHub"
         } else {
-            "Sign in to use GitHub Copilot"
+            "Sign In"
         };
 
         Button::new("sign_in", label)
             .map(|this| {
-                if edit_prediction {
+                if edit_prediction || self.compact {
                     this.size(ButtonSize::Medium)
                 } else {
                     this.full_width()
                 }
             })
             .style(ButtonStyle::Outlined)
-            .start_icon(
-                Icon::new(IconName::Github)
-                    .size(IconSize::Small)
-                    .color(Color::Muted),
-            )
             .when(edit_prediction, |this| this.tab_index(0isize))
             .on_click(|_, window, cx| {
                 let app_state = AppState::global(cx);
@@ -582,7 +595,7 @@ impl ConfigurationView {
 
         Button::new("reinstall_and_sign_in", label)
             .map(|this| {
-                if edit_prediction {
+                if edit_prediction || self.compact {
                     this.size(ButtonSize::Medium)
                 } else {
                     this.full_width()
@@ -656,31 +669,32 @@ impl ConfigurationView {
         let start_label = "To use Zed's agent with GitHub Copilot, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot Chat subscription.";
         let no_status_label = "Copilot Chat requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different LLM provider.";
 
-        if let Some(msg) = self.loading_message() {
-            v_flex()
-                .gap_2()
-                .child(Label::new(start_label))
-                .child(self.render_loading_button(msg, false))
-                .into_any_element()
+        let (label, button) = if let Some(msg) = self.loading_message() {
+            (
+                start_label,
+                self.render_loading_button(msg, false).into_any_element(),
+            )
         } else if self.is_error() {
-            v_flex()
-                .gap_2()
-                .child(Label::new(ERROR_LABEL))
-                .child(self.render_reinstall_button(false))
-                .into_any_element()
+            (
+                ERROR_LABEL,
+                self.render_reinstall_button(false).into_any_element(),
+            )
         } else if self.has_no_status() {
-            v_flex()
-                .gap_2()
-                .child(Label::new(no_status_label))
-                .child(self.render_sign_in_button(false))
-                .into_any_element()
+            (
+                no_status_label,
+                self.render_sign_in_button(false).into_any_element(),
+            )
         } else {
-            v_flex()
-                .gap_2()
-                .child(Label::new(start_label))
-                .child(self.render_sign_in_button(false))
-                .into_any_element()
-        }
+            (
+                start_label,
+                self.render_sign_in_button(false).into_any_element(),
+            )
+        };
+
+        v_flex()
+            .gap_2()
+            .when(!self.compact, |this| this.child(Label::new(label)))
+            .child(button)
     }
 }
 
@@ -689,13 +703,15 @@ impl Render for ConfigurationView {
         let is_authenticated = &self.is_authenticated;
 
         if is_authenticated(cx) {
-            return ConfiguredApiCard::new("Authorized")
+            let sign_out = |_: &gpui::ClickEvent, window: &mut Window, cx: &mut App| {
+                if let Some(auth) = GlobalCopilotAuth::try_global(cx) {
+                    initiate_sign_out(auth.0.clone(), window, cx);
+                }
+            };
+
+            return ConfiguredApiCard::new("copilot-authorized", "Authorized")
                 .button_label("Sign Out")
-                .on_click(|_, window, cx| {
-                    if let Some(auth) = GlobalCopilotAuth::try_global(cx) {
-                        initiate_sign_out(auth.0.clone(), window, cx);
-                    }
-                })
+                .on_click(sign_out)
                 .into_any_element();
         }
 
diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs
index 2552eacf102455..31096cc38bcd3b 100644
--- a/crates/feature_flags/src/flags.rs
+++ b/crates/feature_flags/src/flags.rs
@@ -111,17 +111,13 @@ impl FeatureFlag for AgentThreadWorktreeLabelFlag {
 }
 register_feature_flag!(AgentThreadWorktreeLabelFlag);
 
-/// Moves LLM provider and MCP server configuration out of the dedicated agent
-/// panel page and into the settings UI. When enabled, the agent panel no longer
-/// shows its configuration overlay and the settings UI exposes the "LLM
-/// Providers" and "MCP Servers" sub-pages instead.
-pub struct AgentSettingsUiFeatureFlag;
-
-impl FeatureFlag for AgentSettingsUiFeatureFlag {
-    const NAME: &'static str = "agent-settings-ui";
+pub struct AutoWatchFeatureFlag;
+
+impl FeatureFlag for AutoWatchFeatureFlag {
+    const NAME: &'static str = "auto-watch-screens";
     type Value = PresenceFlag;
 }
-register_feature_flag!(AgentSettingsUiFeatureFlag);
+register_feature_flag!(AutoWatchFeatureFlag);
 
 /// Wraps agent-run terminal commands in an OS-level sandbox where supported
 /// (currently macOS Seatbelt only). When off, terminal commands run with the
diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs
index df911d1b77ba6b..7a8aa00f92daf2 100644
--- a/crates/language_model/src/language_model.rs
+++ b/crates/language_model/src/language_model.rs
@@ -371,6 +371,22 @@ pub trait LanguageModelProvider: 'static {
         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.
@@ -382,13 +398,31 @@ pub trait LanguageModelProvider: 'static {
 /// How a provider's configuration UI prefers to be presented by the settings UI.
 #[derive(Clone)]
 pub enum ProviderConfigurationView {
-    /// A compact control suitable for rendering inline in a list row, such as a
-    /// single API-key field.
-    Inline(AnyView),
-    /// A richer view that should be shown on its own dedicated sub-page.
+    Inline { view: AnyView },
     SubPage(AnyView),
 }
 
+/// A live snapshot of a single-API-key provider's credential state, used by the
+/// settings UI to render the provider's "API Key" section.
+#[derive(Clone)]
+pub struct ApiKeyConfiguration {
+    pub has_key: bool,
+    pub is_from_env_var: bool,
+    pub env_var_name: SharedString,
+    pub api_key_url: SharedString,
+}
+
+/// The subtitle rendered beneath a provider's name when its configuration is
+/// shown inline.
+#[derive(Clone)]
+pub enum InlineDescription {
+    /// A clickable "Where to find key" link pointing at the given URL, for
+    /// API-key based providers.
+    ApiKeyUrl(SharedString),
+    /// Plain descriptive text, e.g. explaining a sign-in based provider.
+    Text(SharedString),
+}
+
 /// Provider-specific copy shown the first time a user enables fast mode.
 #[derive(Debug, Clone)]
 pub struct FastModeConfirmation {
diff --git a/crates/language_models/src/api_key_editor.rs b/crates/language_models/src/api_key_editor.rs
deleted file mode 100644
index d6a9182a662b4b..00000000000000
--- a/crates/language_models/src/api_key_editor.rs
+++ /dev/null
@@ -1,155 +0,0 @@
-use std::rc::Rc;
-
-use anyhow::Result;
-use gpui::{App, Context, Entity, Subscription, Task, Window};
-use language_model::ApiKeyState;
-use ui::{Tooltip, prelude::*};
-use ui_input::InputField;
-
-/// The current credential state of a single-API-key provider, as reported by the
-/// provider when constructing an [`ApiKeyEditor`].
-pub enum ApiKeyStatus {
-    /// No key is configured; show the input field.
-    Unset,
-    /// A key is configured via the UI; show a "configured" row with a reset.
-    Configured,
-    /// The key comes from an environment variable and can't be edited here.
-    FromEnvVar(SharedString),
-}
-
-/// Maps a provider's [`ApiKeyState`] to the [`ApiKeyStatus`] the editor renders.
-/// Shared so the API-key providers don't each duplicate this mapping.
-pub fn api_key_status(state: &ApiKeyState) -> ApiKeyStatus {
-    if state.is_from_env_var() {
-        ApiKeyStatus::FromEnvVar(state.env_var_name().clone())
-    } else if state.has_key() {
-        ApiKeyStatus::Configured
-    } else {
-        ApiKeyStatus::Unset
-    }
-}
-
-/// A compact, reusable control for editing a provider's single API key, intended
-/// to be returned from `LanguageModelProvider::configuration_view_v2` as an
-/// inline control.
-///
-/// It is deliberately provider-agnostic: the provider supplies closures that
-/// read the current [`ApiKeyStatus`] and store/clear the key against its own
-/// state, so all credential knowledge stays in the provider.
-pub struct ApiKeyEditor {
-    input: Entity,
-    api_key_url: SharedString,
-    status: Rc ApiKeyStatus>,
-    set_key: Rc Task>>,
-    reset_key: Rc Task>>,
-    _subscription: Subscription,
-}
-
-impl ApiKeyEditor {
-    pub fn new(
-        state: Entity,
-        api_key_url: impl Into,
-        placeholder: &str,
-        status: impl Fn(&S, &App) -> ApiKeyStatus + 'static,
-        set_key: impl Fn(&Entity, String, &mut App) -> Task> + 'static,
-        reset_key: impl Fn(&Entity, &mut App) -> Task> + 'static,
-        window: &mut Window,
-        cx: &mut Context,
-    ) -> Self {
-        let input = cx.new(|cx| {
-            InputField::new(window, cx, placeholder)
-                .masked(true)
-                .tab_index(0)
-        });
-        let subscription = cx.observe(&state, |_, _, cx| cx.notify());
-
-        let status_state = state.clone();
-        let set_state = state.clone();
-        Self {
-            input,
-            api_key_url: api_key_url.into(),
-            status: Rc::new(move |cx| status(status_state.read(cx), cx)),
-            set_key: Rc::new(move |key, cx| set_key(&set_state, key, cx)),
-            reset_key: Rc::new(move |cx| reset_key(&state, cx)),
-            _subscription: subscription,
-        }
-    }
-
-    fn save(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) {
-        let key = self.input.read(cx).text(cx).trim().to_string();
-        if key.is_empty() {
-            return;
-        }
-        self.input
-            .update(cx, |input, cx| input.set_text("", window, cx));
-        (self.set_key.clone())(key, cx).detach_and_log_err(cx);
-    }
-
-    fn reset(&mut self, cx: &mut Context) {
-        (self.reset_key.clone())(cx).detach_and_log_err(cx);
-    }
-
-    fn render_where_to_find_key(&self) -> impl IntoElement {
-        let url = self.api_key_url.clone();
-        let click_url = url.to_string();
-        h_flex()
-            .id("where-to-find-key")
-            .gap_0p5()
-            .cursor_pointer()
-            .child(
-                Icon::new(IconName::Info)
-                    .size(IconSize::XSmall)
-                    .color(Color::Muted),
-            )
-            .child(
-                Label::new("Where to find key")
-                    .size(LabelSize::Small)
-                    .color(Color::Muted),
-            )
-            .tooltip(Tooltip::text(format!("Create an API key at {url}")))
-            .on_click(move |_, _window, cx| cx.open_url(&click_url))
-    }
-}
-
-impl Render for ApiKeyEditor {
-    fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
-        match (self.status)(cx) {
-            ApiKeyStatus::FromEnvVar(env_var_name) => Label::new(format!("Set via {env_var_name}"))
-                .size(LabelSize::Small)
-                .color(Color::Muted)
-                .into_any_element(),
-            ApiKeyStatus::Configured => h_flex()
-                .gap_2()
-                .items_center()
-                .child(
-                    Icon::new(IconName::Check)
-                        .size(IconSize::Small)
-                        .color(Color::Success),
-                )
-                .child(
-                    Label::new("Configured")
-                        .size(LabelSize::Small)
-                        .color(Color::Muted),
-                )
-                .child(
-                    Button::new("reset-api-key", "Reset")
-                        .style(ButtonStyle::Outlined)
-                        .label_size(LabelSize::Small)
-                        .tab_index(0isize)
-                        .on_click(cx.listener(|this, _, _window, cx| this.reset(cx))),
-                )
-                .into_any_element(),
-            ApiKeyStatus::Unset => v_flex()
-                .w_full()
-                .gap_1()
-                .child(self.render_where_to_find_key())
-                .child(
-                    div()
-                        .w_full()
-                        .on_action(cx.listener(Self::save))
-                        .child(self.input.clone()),
-                )
-                .into_any_element(),
-        }
-    }
-}
diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs
index 1133ee2d47760e..a9ec2b028ff46d 100644
--- a/crates/language_models/src/language_models.rs
+++ b/crates/language_models/src/language_models.rs
@@ -10,12 +10,10 @@ use language_model::{
 };
 use provider::deepseek::DeepSeekLanguageModelProvider;
 
-mod api_key_editor;
 pub mod extension;
 pub mod provider;
 mod settings;
 
-pub use crate::api_key_editor::{ApiKeyEditor, ApiKeyStatus, api_key_status};
 pub use crate::extension::init_proxy as init_extension_proxy;
 
 use crate::provider::anthropic::AnthropicLanguageModelProvider;
diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs
index 46ef381f3127df..1841a9619d2537 100644
--- a/crates/language_models/src/provider/anthropic.rs
+++ b/crates/language_models/src/provider/anthropic.rs
@@ -8,12 +8,12 @@ use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt};
 use http_client::{CustomHeaders, HttpClient};
 use language_model::{
-    ANTHROPIC_PROVIDER_ID, ANTHROPIC_PROVIDER_NAME, ApiKeyState, AuthenticateError,
-    ConfigurationViewTargetAgent, EnvVar, FastModeConfirmation, IconOrSvg, LanguageModel,
-    LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName,
-    LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
-    LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
-    ProviderConfigurationView, RateLimiter, env_var,
+    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,
 };
 use settings::{Settings, SettingsStore};
 use std::sync::{Arc, LazyLock};
@@ -290,28 +290,19 @@ impl LanguageModelProvider for AnthropicLanguageModelProvider {
             .update(cx, |state, cx| state.set_api_key(None, cx))
     }
 
-    fn configuration_view_v2(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> ProviderConfigurationView {
-        let state = self.state.clone();
-        ProviderConfigurationView::Inline(
-            cx.new(|cx| {
-                crate::ApiKeyEditor::new(
-                    state,
-                    "https://console.anthropic.com/settings/keys",
-                    "sk-ant-...",
-                    |state, _cx| crate::api_key_status(&state.api_key_state),
-                    |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)),
-                    |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)),
-                    window,
-                    cx,
-                )
-            })
-            .into(),
-        )
+    fn api_key_configuration(&self, cx: &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(),
+        })
+    }
+
+    fn set_api_key(&self, key: String, cx: &mut App) -> Task> {
+        self.state
+            .update(cx, |state, cx| state.set_api_key(Some(key), cx))
     }
 
     fn fast_mode_confirmation(&self, _cx: &App) -> Option {
@@ -702,7 +693,7 @@ impl Render for ConfigurationView {
                 )
                 .into_any_element()
         } else {
-            ConfiguredApiCard::new(configured_card_label)
+            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| {
diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs
index 755d23f52a22a2..4d843009f7623c 100644
--- a/crates/language_models/src/provider/bedrock.rs
+++ b/crates/language_models/src/provider/bedrock.rs
@@ -32,11 +32,12 @@ use gpui::{
 use gpui_tokio::Tokio;
 use http_client::HttpClient;
 use language_model::{
-    AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
-    LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
-    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
-    LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
-    LanguageModelToolUse, MessageContent, RateLimiter, Role, TokenUsage, env_var,
+    AuthenticateError, EnvVar, IconOrSvg, InlineDescription, LanguageModel,
+    LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName,
+    LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
+    LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
+    LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, RateLimiter, Role,
+    TokenUsage, env_var,
 };
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
@@ -460,6 +461,12 @@ 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()))
     }
@@ -1492,10 +1499,6 @@ impl ConfigurationView {
             .detach_and_log_err(cx);
     }
 
-    fn should_render_editor(&self, cx: &Context) -> bool {
-        self.state.read(cx).is_authenticated()
-    }
-
     fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context) {
         window.focus_next(cx);
     }
@@ -1577,13 +1580,15 @@ impl Render for ConfigurationView {
             None
         };
 
-        if self.should_render_editor(cx) {
-            return ConfiguredApiCard::new(configured_label)
+        let credentials_control = if self.state.read(cx).is_authenticated() {
+            ConfiguredApiCard::new("bedrock-reset", configured_label)
                 .disabled(env_var_set || is_settings_derived)
                 .on_click(cx.listener(|this, _, window, cx| this.reset_credentials(window, cx)))
                 .when_some(tooltip_label, |this, label| this.tooltip_label(label))
-                .into_any_element();
-        }
+                .into_any_element()
+        } else {
+            self.render_static_credentials_ui().into_any_element()
+        };
 
         v_flex()
             .min_w_0()
@@ -1592,15 +1597,29 @@ impl Render for ConfigurationView {
             .on_action(cx.listener(Self::on_tab))
             .on_action(cx.listener(Self::on_tab_prev))
             .on_action(cx.listener(ConfigurationView::save_credentials))
-            .child(Label::new("To use Zed's agent with Bedrock, you can set a custom authentication strategy through your settings file or use static credentials."))
-            .child(Label::new("But first, to access models on AWS, you need to:").mt_1())
+            .gap_1()
+            .child(Headline::new("Amazon Bedrock").size(HeadlineSize::Small))
+            .child(
+                Label::new(
+                    "To use Zed's agent with Bedrock, you can set a custom authentication strategy through your settings file or use static credentials.",
+                )
+                .color(Color::Muted),
+            )
+            .child(
+                Label::new("But first, to access models on AWS, you need to:")
+                    .mt_1()
+                    .color(Color::Muted),
+            )
             .child(
                 List::new()
                     .child(
                         ListBulletItem::new("")
-                            .child(Label::new(
-                                "Grant permissions to the strategy you'll use according to the:",
-                            ))
+                            .child(
+                                Label::new(
+                                    "Grant permissions to the strategy you'll use according to the:",
+                                )
+                                .color(Color::Muted),
+                            )
                             .child(ButtonLink::new(
                                 "Prerequisites",
                                 "https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html",
@@ -1608,33 +1627,32 @@ impl Render for ConfigurationView {
                     )
                     .child(
                         ListBulletItem::new("")
-                            .child(Label::new("Select the models you would like access to:"))
+                            .child(
+                                Label::new("Select the models you would like access to:")
+                                    .color(Color::Muted),
+                            )
                             .child(ButtonLink::new(
                                 "Bedrock Model Catalog",
                                 "https://us-east-1.console.aws.amazon.com/bedrock/home?region=us-east-1#/model-catalog",
                             )),
                     ),
             )
-            .child(self.render_static_credentials_ui())
+            .child(credentials_control)
             .into_any()
     }
 }
 
 impl ConfigurationView {
     fn render_static_credentials_ui(&self) -> impl IntoElement {
-        let section_header = |title: SharedString| {
-            h_flex()
-                .gap_2()
-                .child(Label::new(title).size(LabelSize::Default))
-                .child(Divider::horizontal())
-        };
-
         let list_item = List::new()
             .child(
                 ListBulletItem::new("")
-                    .child(Label::new(
-                        "For access keys: Create an IAM user in the AWS console with programmatic access",
-                    ))
+                    .child(
+                        Label::new(
+                            "For access keys: Create an IAM user in the AWS console with programmatic access",
+                        )
+                        .color(Color::Muted),
+                    )
                     .child(ButtonLink::new(
                         "IAM Console",
                         "https://us-east-1.console.aws.amazon.com/iam/home?region=us-east-1#/users",
@@ -1642,7 +1660,10 @@ impl ConfigurationView {
             )
             .child(
                 ListBulletItem::new("")
-                    .child(Label::new("For Bedrock API Keys: Generate an API key from the"))
+                    .child(
+                        Label::new("For Bedrock API Keys: Generate an API key from the")
+                            .color(Color::Muted),
+                    )
                     .child(ButtonLink::new(
                         "Bedrock Console",
                         "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys-use.html",
@@ -1650,28 +1671,42 @@ impl ConfigurationView {
             )
             .child(
                 ListBulletItem::new("")
-                    .child(Label::new("Attach the necessary Bedrock permissions to"))
+                    .child(
+                        Label::new("Attach the necessary Bedrock permissions to")
+                            .color(Color::Muted),
+                    )
                     .child(ButtonLink::new(
                         "this user",
                         "https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html",
                     )),
             )
-            .child(ListBulletItem::new(
-                "Enter either access keys OR a Bedrock API Key below (not both)",
-            ));
+            .child(
+                ListBulletItem::new(
+                    "Enter either access keys OR a Bedrock API Key below (not both)",
+                )
+                .label_color(Color::Muted),
+            );
 
         v_flex()
             .my_2()
             .tab_group()
             .gap_1p5()
-            .child(section_header("Static Credentials".into()))
-            .child(Label::new(
-                "This method uses your AWS access key ID and secret access key, or a Bedrock API Key.",
-            ))
+            .child(Divider::horizontal())
+            .child(Label::new("Static Credentials").mt_2())
+            .child(
+                Label::new(
+                    "This method uses your AWS access key ID and secret access key, or a Bedrock API Key.",
+                )
+                .color(Color::Muted),
+            )
             .child(list_item)
-            .child(self.access_key_id_editor.clone())
-            .child(self.secret_access_key_editor.clone())
-            .child(self.session_token_editor.clone())
+            .child(
+                v_flex()
+                    .gap_1()
+                    .child(self.access_key_id_editor.clone())
+                    .child(self.secret_access_key_editor.clone())
+                    .child(self.session_token_editor.clone()),
+            )
             .child(
                 Label::new(format!(
                     "You can also set the {}, {} and {} environment variables (or {} for Bedrock API Key authentication) and restart Zed.",
@@ -1695,7 +1730,8 @@ impl ConfigurationView {
                 .mt_1()
                 .mb_2p5(),
             )
-            .child(section_header("Using the an API key".into()))
+            .child(Divider::horizontal())
+            .child(Label::new("Using the API key").mt_2().mb_1())
             .child(self.bearer_token_editor.clone())
             .child(
                 Label::new(format!(
diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs
index cdd4bb6c8aae0e..926f99eae1b60a 100644
--- a/crates/language_models/src/provider/cloud.rs
+++ b/crates/language_models/src/provider/cloud.rs
@@ -11,9 +11,10 @@ use futures::StreamExt;
 use futures::future::BoxFuture;
 use gpui::{AnyElement, AnyView, App, AppContext, Context, Entity, Subscription, Task, TaskExt};
 use language_model::{
-    AuthenticateError, FastModeConfirmation, IconOrSvg, LanguageModel, LanguageModelProvider,
-    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
-    ProviderConfigurationView, ZED_CLOUD_PROVIDER_ID, ZED_CLOUD_PROVIDER_NAME,
+    AuthenticateError, FastModeConfirmation, IconOrSvg, InlineDescription, LanguageModel,
+    LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
+    LanguageModelProviderState, ProviderConfigurationView, ZED_CLOUD_PROVIDER_ID,
+    ZED_CLOUD_PROVIDER_NAME,
 };
 use language_models_cloud::{CloudLlmTokenProvider, CloudModelProvider};
 use rand::{Rng as _, SeedableRng as _, rngs::StdRng};
@@ -365,20 +366,55 @@ impl LanguageModelProvider for CloudLanguageModelProvider {
         _: &mut Window,
         cx: &mut App,
     ) -> AnyView {
-        cx.new(|_| ConfigurationView::new(self.state.clone()))
+        cx.new(|_| ConfigurationView::new(self.state.clone(), false))
             .into()
     }
 
     fn configuration_view_v2(
         &self,
-        target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
+        _target_agent: language_model::ConfigurationViewTargetAgent,
+        _window: &mut Window,
         cx: &mut App,
     ) -> ProviderConfigurationView {
-        // The Zed sign-in/plan control is small enough that sending users to a
-        // dedicated sub-page just to reach it would be annoying, so render it
-        // inline even though it isn't an API-key field.
-        ProviderConfigurationView::Inline(self.configuration_view(target_agent, window, cx))
+        ProviderConfigurationView::Inline {
+            view: cx
+                .new(|_| ConfigurationView::new(self.state.clone(), true))
+                .into(),
+        }
+    }
+
+    fn inline_description(&self, cx: &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(
+            zed_ai_description(
+                !state.is_signed_out(cx),
+                user_store.plan(),
+                is_zed_model_provider_enabled,
+                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,
+        };
+        Some(format!("Subscribed to {plan_name}").into())
     }
 
     fn reset_credentials(&self, _cx: &mut App) -> Task> {
@@ -413,63 +449,86 @@ struct ZedAiConfiguration {
     is_zed_model_provider_enabled: bool,
     eligible_for_trial: bool,
     account_too_young: bool,
+    compact: bool,
     sign_in_callback: Arc,
 }
 
+fn zed_ai_description(
+    is_connected: bool,
+    plan: Option,
+    is_zed_model_provider_enabled: bool,
+    eligible_for_trial: bool,
+) -> &'static str {
+    if !is_connected {
+        return "Sign in to have access to Zed's complete agentic experience with hosted models.";
+    }
+
+    match plan {
+        Some(Plan::ZedPro) => {
+            "You have access to Zed's hosted models through your Pro subscription."
+        }
+        Some(Plan::ZedProTrial) => "You have access to Zed's hosted models through your Pro trial.",
+        Some(Plan::ZedStudent) => {
+            "You have access to Zed's hosted models through your Student subscription."
+        }
+        Some(Plan::ZedBusiness) => {
+            if is_zed_model_provider_enabled {
+                "You have access to Zed's hosted models through your organization."
+            } else {
+                "Zed's hosted models are disabled by your organization's configuration."
+            }
+        }
+        Some(Plan::ZedVip) => {
+            "You have access to Zed's hosted models through your VIP subscription."
+        }
+        Some(Plan::ZedFree) | None => {
+            if eligible_for_trial {
+                "Subscribe for access to Zed's hosted models. Start with a 14 day free trial."
+            } else {
+                "Subscribe for access to Zed's hosted models."
+            }
+        }
+    }
+}
+
 impl RenderOnce for ZedAiConfiguration {
     fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
-        let (subscription_text, has_paid_plan) = match self.plan {
-            Some(Plan::ZedPro) => (
-                "You have access to Zed's hosted models through your Pro subscription.",
-                true,
-            ),
-            Some(Plan::ZedProTrial) => (
-                "You have access to Zed's hosted models through your Pro trial.",
-                false,
-            ),
-            Some(Plan::ZedStudent) => (
-                "You have access to Zed's hosted models through your Student subscription.",
-                true,
-            ),
-            Some(Plan::ZedBusiness) => (
-                if self.is_zed_model_provider_enabled {
-                    "You have access to Zed's hosted models through your organization."
-                } else {
-                    "Zed's hosted models are disabled by your organization's configuration."
-                },
-                true,
-            ),
-            Some(Plan::ZedVip) => (
-                "You have access to Zed's hosted models through your VIP subscription.",
-                true,
-            ),
+        let has_paid_plan = matches!(
+            self.plan,
+            Some(Plan::ZedPro | Plan::ZedStudent | Plan::ZedBusiness | Plan::ZedVip)
+        );
 
-            Some(Plan::ZedFree) | None => (
-                if self.eligible_for_trial {
-                    "Subscribe for access to Zed's hosted models. Start with a 14 day free trial."
-                } else {
-                    "Subscribe for access to Zed's hosted models."
-                },
-                false,
-            ),
-        };
+        let description = zed_ai_description(
+            self.is_connected,
+            self.plan,
+            self.is_zed_model_provider_enabled,
+            self.eligible_for_trial,
+        );
 
         let manage_subscription_buttons = if has_paid_plan {
             Button::new("manage_settings", "Manage Subscription")
-                .full_width()
-                .label_size(LabelSize::Small)
+                .when(!self.compact, |this| {
+                    this.full_width().label_size(LabelSize::Small)
+                })
+                .when(self.compact, |this| this.size(ButtonSize::Medium))
                 .style(ButtonStyle::Tinted(TintColor::Accent))
                 .on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx)))
                 .into_any_element()
         } else if self.plan.is_none() || self.eligible_for_trial {
             Button::new("start_trial", "Start 14-day Free Pro Trial")
-                .full_width()
+                .when(!self.compact, |this| {
+                    this.full_width().label_size(LabelSize::Small)
+                })
+                .when(self.compact, |this| this.size(ButtonSize::Medium))
                 .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent))
                 .on_click(|_, _, cx| cx.open_url(&zed_urls::start_trial_url(cx)))
                 .into_any_element()
         } else {
             Button::new("upgrade", "Upgrade to Pro")
-                .full_width()
+                .when(!self.compact, |this| {
+                    this.full_width().label_size(LabelSize::Small)
+                })
+                .when(self.compact, |this| this.size(ButtonSize::Medium))
                 .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent))
                 .on_click(|_, _, cx| cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx)))
                 .into_any_element()
@@ -478,11 +537,15 @@ impl RenderOnce for ZedAiConfiguration {
         if !self.is_connected {
             return v_flex()
                 .gap_2()
-                .child(Label::new("Sign in to have access to Zed's complete agentic experience with hosted models."))
+                .when(!self.compact, |this| this.child(Label::new(description)))
                 .child(
                     Button::new("sign_in", "Sign In to use Zed AI")
-                        .start_icon(Icon::new(IconName::Github).size(IconSize::Small).color(Color::Muted))
-                        .full_width()
+                        .start_icon(
+                            Icon::new(IconName::Github)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        )
+                        .when(!self.compact, |this| this.full_width())
                         .on_click({
                             let callback = self.sign_in_callback.clone();
                             move |_, window, cx| (callback)(window, cx)
@@ -490,30 +553,35 @@ impl RenderOnce for ZedAiConfiguration {
                 );
         }
 
-        v_flex().gap_2().w_full().map(|this| {
-            if self.account_too_young {
-                this.child(YoungAccountBanner).child(
-                    Button::new("upgrade", "Upgrade to Pro")
-                        .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent))
-                        .full_width()
-                        .on_click(|_, _, cx| cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))),
-                )
-            } else {
-                this.text_sm()
-                    .child(subscription_text)
-                    .child(manage_subscription_buttons)
-            }
-        })
+        v_flex()
+            .gap_2()
+            .when(!self.compact, |this| this.w_full())
+            .map(|this| {
+                if self.account_too_young {
+                    this.child(YoungAccountBanner).child(
+                        Button::new("upgrade", "Upgrade to Pro")
+                            .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent))
+                            .when(!self.compact, |this| this.full_width())
+                            .on_click(|_, _, cx| {
+                                cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))
+                            }),
+                    )
+                } else {
+                    this.when(!self.compact, |this| this.text_sm().child(description))
+                        .child(manage_subscription_buttons)
+                }
+            })
     }
 }
 
 struct ConfigurationView {
     state: Entity,
+    compact: bool,
     sign_in_callback: Arc,
 }
 
 impl ConfigurationView {
-    fn new(state: Entity) -> Self {
+    fn new(state: Entity, compact: bool) -> Self {
         let sign_in_callback = Arc::new({
             let state = state.clone();
             move |_window: &mut Window, cx: &mut App| {
@@ -525,6 +593,7 @@ impl ConfigurationView {
 
         Self {
             state,
+            compact,
             sign_in_callback,
         }
     }
@@ -545,6 +614,7 @@ impl Render for ConfigurationView {
             is_zed_model_provider_enabled,
             eligible_for_trial: user_store.trial_started_at().is_none(),
             account_too_young: user_store.account_too_young(),
+            compact: self.compact,
             sign_in_callback: self.sign_in_callback.clone(),
         }
     }
@@ -904,6 +974,7 @@ impl Component for ZedAiConfiguration {
                 is_zed_model_provider_enabled: config.is_zed_model_provider_enabled,
                 eligible_for_trial: config.eligible_for_trial,
                 account_too_young: false,
+                compact: false,
                 sign_in_callback: Arc::new(|_, _| {}),
             }
             .into_any_element()
diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs
index 0d3b38d31155aa..94bc00e72d5c78 100644
--- a/crates/language_models/src/provider/copilot_chat.rs
+++ b/crates/language_models/src/provider/copilot_chat.rs
@@ -199,13 +199,48 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider {
 
     fn configuration_view_v2(
         &self,
-        target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
+        _target_agent: language_model::ConfigurationViewTargetAgent,
+        _window: &mut Window,
         cx: &mut App,
     ) -> ProviderConfigurationView {
-        // GitHub Copilot's control is just a sign-in button, so render it inline
-        // rather than behind a sub-page.
-        ProviderConfigurationView::Inline(self.configuration_view(target_agent, window, cx))
+        // 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) {
+            None
+        } else {
+            Some("Configure Copilot".into())
+        }
+    }
+
+    fn inline_description(&self, cx: &App) -> Option {
+        if self.state.read(cx).is_authenticated(cx) {
+            None
+        } else {
+            Some(language_model::InlineDescription::Text(
+                "Requires an active GitHub Copilot subscription.".into(),
+            ))
+        }
     }
 
     fn reset_credentials(&self, _cx: &mut App) -> Task> {
diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs
index 48096f168490aa..943b3861e3c513 100644
--- a/crates/language_models/src/provider/deepseek.rs
+++ b/crates/language_models/src/provider/deepseek.rs
@@ -8,12 +8,12 @@ use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window};
 use http_client::{CustomHeaders, HttpClient};
 use language_model::{
-    ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
-    LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName,
-    LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
-    LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
-    LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
-    ProviderConfigurationView, RateLimiter, Role, StopReason, TokenUsage, env_var,
+    ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel,
+    LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel,
+    LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
+    LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
+    LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
+    RateLimiter, Role, StopReason, TokenUsage, env_var,
 };
 pub use settings::DeepseekAvailableModel as AvailableModel;
 use settings::{Settings, SettingsStore};
@@ -212,28 +212,19 @@ impl LanguageModelProvider for DeepSeekLanguageModelProvider {
             .update(cx, |state, cx| state.set_api_key(None, cx))
     }
 
-    fn configuration_view_v2(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> ProviderConfigurationView {
-        let state = self.state.clone();
-        ProviderConfigurationView::Inline(
-            cx.new(|cx| {
-                crate::ApiKeyEditor::new(
-                    state,
-                    "https://platform.deepseek.com/api_keys",
-                    "Paste your DeepSeek API key",
-                    |state, _cx| crate::api_key_status(&state.api_key_state),
-                    |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)),
-                    |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)),
-                    window,
-                    cx,
-                )
-            })
-            .into(),
-        )
+    fn api_key_configuration(&self, cx: &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(),
+        })
+    }
+
+    fn set_api_key(&self, key: String, cx: &mut App) -> Task> {
+        self.state
+            .update(cx, |state, cx| state.set_api_key(Some(key), cx))
     }
 }
 
@@ -780,7 +771,7 @@ impl Render for ConfigurationView {
                 )
                 .into_any_element()
         } else {
-            ConfiguredApiCard::new(configured_card_label)
+            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 7ea54a51ba3306..8a289fd2e05998 100644
--- a/crates/language_models/src/provider/google.rs
+++ b/crates/language_models/src/provider/google.rs
@@ -7,9 +7,9 @@ pub use google_ai::completion::{GoogleEventMapper, into_google};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window};
 use http_client::{CustomHeaders, HttpClient};
 use language_model::{
-    AuthenticateError, ConfigurationViewTargetAgent, EnvVar, LanguageModelCompletionError,
-    LanguageModelCompletionEvent, LanguageModelToolChoice, LanguageModelToolSchemaFormat,
-    ProviderConfigurationView,
+    ApiKeyConfiguration, AuthenticateError, ConfigurationViewTargetAgent, EnvVar,
+    LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelToolChoice,
+    LanguageModelToolSchemaFormat,
 };
 use language_model::{
     GOOGLE_PROVIDER_ID, GOOGLE_PROVIDER_NAME, IconOrSvg, LanguageModel, LanguageModelEffortLevel,
@@ -237,28 +237,19 @@ impl LanguageModelProvider for GoogleLanguageModelProvider {
             .update(cx, |state, cx| state.set_api_key(None, cx))
     }
 
-    fn configuration_view_v2(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> ProviderConfigurationView {
-        let state = self.state.clone();
-        ProviderConfigurationView::Inline(
-            cx.new(|cx| {
-                crate::ApiKeyEditor::new(
-                    state,
-                    "https://aistudio.google.com/app/apikey",
-                    "AIza...",
-                    |state, _cx| crate::api_key_status(&state.api_key_state),
-                    |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)),
-                    |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)),
-                    window,
-                    cx,
-                )
-            })
-            .into(),
-        )
+    fn api_key_configuration(&self, cx: &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(),
+        })
+    }
+
+    fn set_api_key(&self, key: String, cx: &mut App) -> Task> {
+        self.state
+            .update(cx, |state, cx| state.set_api_key(Some(key), cx))
     }
 }
 
@@ -526,7 +517,7 @@ impl Render for ConfigurationView {
                 )
                 .into_any_element()
         } else {
-            ConfiguredApiCard::new(configured_card_label)
+            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| {
diff --git a/crates/language_models/src/provider/llama_cpp.rs b/crates/language_models/src/provider/llama_cpp.rs
index 921a2ae63e530f..ed207d4b47bbca 100644
--- a/crates/language_models/src/provider/llama_cpp.rs
+++ b/crates/language_models/src/provider/llama_cpp.rs
@@ -4,15 +4,16 @@ use credentials_provider::CredentialsProvider;
 use fs::Fs;
 use futures::Stream;
 use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream};
-use gpui::{AnyView, App, AsyncApp, Context, CursorStyle, Entity, Task, TaskExt};
+use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt};
 use http_client::{CustomHeaders, HttpClient};
 use language_model::util::parse_tool_arguments;
 use language_model::{
-    ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
-    LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
-    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
-    LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
-    LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var,
+    ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, InlineDescription, LanguageModel,
+    LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName,
+    LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
+    LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
+    LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, RateLimiter, Role,
+    StopReason, TokenUsage, env_var,
 };
 use llama_cpp::{
     LLAMA_CPP_API_URL, ModelEntry, Props, get_models, get_props, stream_chat_completion,
@@ -25,8 +26,7 @@ use std::sync::LazyLock;
 use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard};
 use std::time::Duration;
 use ui::{
-    ButtonLike, ButtonLink, ConfiguredApiCard, ElevationIndex, List, ListBulletItem, Tooltip,
-    prelude::*,
+    ButtonLike, ButtonLink, ConfiguredApiCard, Divider, List, ListBulletItem, Tooltip, prelude::*,
 };
 use ui_input::InputField;
 use util::ResultExt;
@@ -562,6 +562,12 @@ 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);
@@ -1327,31 +1333,42 @@ impl ConfigurationView {
     fn render_instructions(cx: &App) -> Div {
         v_flex()
             .gap_2()
-            .child(Label::new(
-                "Run open models locally with llama.cpp's built-in server, or connect to a \
+            .child(
+                Label::new(
+                    "Run open models locally with llama.cpp's built-in server, or connect to a \
                 remote llama.cpp server.",
-            ))
-            .child(Label::new("To use a local llama.cpp server:"))
+                )
+                .color(Color::Muted),
+            )
+            .child(Label::new("To use a local llama.cpp server:").color(Color::Muted))
             .child(
                 List::new()
                     .child(
                         ListBulletItem::new("")
-                            .child(Label::new("Install llama.cpp from"))
+                            .child(Label::new("Install llama.cpp from").color(Color::Muted))
                             .child(ButtonLink::new("llama.app", LLAMA_CPP_DOWNLOAD_URL)),
                     )
                     .child(
                         ListBulletItem::new("")
-                            .child(Label::new("Start the server in router mode:"))
+                            .child(
+                                Label::new("Start the server in router mode:").color(Color::Muted),
+                            )
                             .child(Label::new("llama serve").inline_code(cx)),
                     )
-                    .child(ListBulletItem::new(
-                        "Click 'Connect' below to start using llama.cpp in Zed",
-                    )),
+                    .child(
+                        ListBulletItem::new(
+                            "Click 'Connect' below to start using llama.cpp in Zed",
+                        )
+                        .label_color(Color::Muted),
+                    ),
             )
-            .child(Label::new(
-                "Alternatively, you can connect to a remote llama.cpp server by specifying its \
+            .child(
+                Label::new(
+                    "Alternatively, you can connect to a remote llama.cpp server by specifying its \
                 URL and API key (set with --api-key, may not be required):",
-            ))
+                )
+                .color(Color::Muted),
+            )
     }
 
     fn render_api_key_editor(&self, cx: &Context) -> impl IntoElement {
@@ -1363,20 +1380,10 @@ impl ConfigurationView {
             "API key configured".to_string()
         };
 
-        if !state.api_key_state.has_key() {
-            v_flex()
-                .on_action(cx.listener(Self::save_api_key))
-                .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()
+        let api_key_control = if !state.api_key_state.has_key() {
+            self.api_key_editor.clone().into_any_element()
         } else {
-            ConfiguredApiCard::new(configured_card_label)
+            ConfiguredApiCard::new("llama-cpp-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| {
@@ -1385,7 +1392,20 @@ impl ConfigurationView {
                     ))
                 })
                 .into_any_element()
-        }
+        };
+
+        v_flex()
+            .on_action(cx.listener(Self::save_api_key))
+            .child(api_key_control)
+            .gap_1p5()
+            .mb_2()
+            .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),
+            )
     }
 
     fn render_context_window_editor(&self, cx: &Context) -> Div {
@@ -1394,26 +1414,26 @@ impl ConfigurationView {
 
         if custom_context_window_set {
             h_flex()
-                .p_3()
+                .p_1()
                 .justify_between()
                 .rounded_md()
                 .border_1()
-                .border_color(cx.theme().colors().border)
-                .bg(cx.theme().colors().elevated_surface_background)
+                .border_color(cx.theme().colors().border_variant)
+                .bg(cx.theme().colors().background.opacity(0.5))
                 .child(
                     h_flex()
-                        .gap_2()
+                        .gap_1()
                         .child(Icon::new(IconName::Check).color(Color::Success))
-                        .child(v_flex().gap_1().child(Label::new(format!(
+                        .child(Label::new(format!(
                             "Context Window: {}",
                             settings.context_window.unwrap_or_default()
-                        )))),
+                        ))),
                 )
                 .child(
                     Button::new("reset-context-window", "Reset")
+                        .style(ButtonStyle::Outlined)
                         .label_size(LabelSize::Small)
                         .start_icon(Icon::new(IconName::Undo).size(IconSize::Small))
-                        .layer(ElevationIndex::ModalSurface)
                         .on_click(
                             cx.listener(|this, _, window, cx| {
                                 this.reset_context_window(window, cx)
@@ -1428,8 +1448,9 @@ impl ConfigurationView {
                     }),
                 )
                 .child(self.context_window_editor.clone())
+                .gap_1p5()
                 .child(
-                    Label::new("Default: discovered from the server")
+                    Label::new("Default: Discovered from the server")
                         .size(LabelSize::Small)
                         .color(Color::Muted),
                 )
@@ -1442,23 +1463,23 @@ impl ConfigurationView {
 
         if custom_api_url_set {
             h_flex()
-                .p_3()
+                .p_1()
                 .justify_between()
                 .rounded_md()
                 .border_1()
-                .border_color(cx.theme().colors().border)
-                .bg(cx.theme().colors().elevated_surface_background)
+                .border_color(cx.theme().colors().border_variant)
+                .bg(cx.theme().colors().background.opacity(0.5))
                 .child(
                     h_flex()
-                        .gap_2()
+                        .gap_1()
                         .child(Icon::new(IconName::Check).color(Color::Success))
-                        .child(v_flex().gap_1().child(Label::new(api_url))),
+                        .child(Label::new(api_url)),
                 )
                 .child(
                     Button::new("reset-api-url", "Reset API URL")
+                        .style(ButtonStyle::Outlined)
                         .label_size(LabelSize::Small)
                         .start_icon(Icon::new(IconName::Undo).size(IconSize::Small))
-                        .layer(ElevationIndex::ModalSurface)
                         .on_click(
                             cx.listener(|this, _, window, cx| this.reset_api_url(window, cx)),
                         ),
@@ -1469,7 +1490,7 @@ impl ConfigurationView {
                     this.save_api_url(cx);
                     cx.notify();
                 }))
-                .gap_2()
+                .gap_1p5()
                 .child(self.api_url_editor.clone())
         }
     }
@@ -1481,12 +1502,15 @@ impl Render for ConfigurationView {
 
         v_flex()
             .gap_2()
+            .child(Headline::new("llama.cpp").size(HeadlineSize::Small))
             .child(Self::render_instructions(cx))
             .child(self.render_api_url_editor(cx))
             .child(self.render_context_window_editor(cx))
             .child(self.render_api_key_editor(cx))
+            .child(Divider::horizontal())
             .child(
                 h_flex()
+                    .pt_2()
                     .w_full()
                     .justify_between()
                     .gap_2()
@@ -1498,7 +1522,8 @@ impl Render for ConfigurationView {
                                 if is_authenticated {
                                     this.child(
                                         Button::new("llama-cpp-webui", "Open WebUI")
-                                            .style(ButtonStyle::Subtle)
+                                            .style(ButtonStyle::OutlinedGhost)
+                                            .size(ButtonSize::Medium)
                                             .end_icon(
                                                 Icon::new(IconName::ArrowUpRight)
                                                     .size(IconSize::XSmall)
@@ -1513,7 +1538,8 @@ impl Render for ConfigurationView {
                                     )
                                     .child(
                                         Button::new("llama-cpp-site", "llama.cpp")
-                                            .style(ButtonStyle::Subtle)
+                                            .style(ButtonStyle::OutlinedGhost)
+                                            .size(ButtonSize::Medium)
                                             .end_icon(
                                                 Icon::new(IconName::ArrowUpRight)
                                                     .size(IconSize::XSmall)
@@ -1527,7 +1553,8 @@ impl Render for ConfigurationView {
                                 } else {
                                     this.child(
                                         Button::new("download_llama_cpp_button", "Get llama.cpp")
-                                            .style(ButtonStyle::Subtle)
+                                            .style(ButtonStyle::OutlinedGhost)
+                                            .size(ButtonSize::Medium)
                                             .end_icon(
                                                 Icon::new(IconName::ArrowUpRight)
                                                     .size(IconSize::XSmall)
@@ -1542,7 +1569,8 @@ impl Render for ConfigurationView {
                             })
                             .child(
                                 Button::new("view-models", "Browse GGUF Models")
-                                    .style(ButtonStyle::Subtle)
+                                    .style(ButtonStyle::OutlinedGhost)
+                                    .size(ButtonSize::Medium)
                                     .end_icon(
                                         Icon::new(IconName::ArrowUpRight)
                                             .size(IconSize::XSmall)
@@ -1555,17 +1583,16 @@ impl Render for ConfigurationView {
                         if is_authenticated {
                             this.child(
                                 ButtonLike::new("connected")
-                                    .disabled(true)
-                                    .cursor_style(CursorStyle::Arrow)
+                                    .size(ButtonSize::Medium)
                                     .child(
                                         h_flex()
-                                            .gap_2()
+                                            .gap_1()
                                             .child(Icon::new(IconName::Check).color(Color::Success))
-                                            .child(Label::new("Connected"))
-                                            .into_any_element(),
+                                            .child(Label::new("Connected")),
                                     )
                                     .child(
                                         IconButton::new("refresh-models", IconName::RotateCcw)
+                                            .icon_size(IconSize::Small)
                                             .tooltip(Tooltip::text("Refresh Models"))
                                             .on_click(cx.listener(|this, _, window, cx| {
                                                 this.state.update(cx, |state, _| {
@@ -1578,6 +1605,8 @@ impl Render for ConfigurationView {
                         } else {
                             this.child(
                                 Button::new("retry_llama_cpp_models", "Connect")
+                                    .style(ButtonStyle::Outlined)
+                                    .size(ButtonSize::Medium)
                                     .start_icon(
                                         Icon::new(IconName::PlayOutlined).size(IconSize::XSmall),
                                     )
diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs
index b7b076125647c9..362db21070ed71 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, CursorStyle, Entity, Subscription, Task, TaskExt};
+use gpui::{AnyView, App, AsyncApp, Context, Entity, Subscription, Task, TaskExt};
 use http_client::{CustomHeaders, HttpClient};
 use language_model::{
     ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
@@ -11,8 +11,9 @@ use language_model::{
     LanguageModelToolUse, MessageContent, StopReason, TokenUsage, env_var,
 };
 use language_model::{
-    LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
-    LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, RateLimiter, Role,
+    InlineDescription, LanguageModelId, LanguageModelName, LanguageModelProvider,
+    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
+    LanguageModelRequest, RateLimiter, Role,
 };
 use lmstudio::{LMSTUDIO_API_URL, ModelType, get_models};
 
@@ -24,9 +25,7 @@ use std::{
     collections::{BTreeMap, HashMap},
     sync::Arc,
 };
-use ui::{
-    ButtonLike, ConfiguredApiCard, ElevationIndex, List, ListBulletItem, Tooltip, prelude::*,
-};
+use ui::{ButtonLike, ConfiguredApiCard, Divider, List, ListBulletItem, Tooltip, prelude::*};
 use ui_input::InputField;
 
 use crate::AllLanguageModelSettings;
@@ -253,6 +252,12 @@ 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
@@ -833,28 +838,8 @@ impl ConfigurationView {
         let custom_api_url_set = api_url != LMSTUDIO_API_URL;
 
         if custom_api_url_set {
-            h_flex()
-                .p_3()
-                .justify_between()
-                .rounded_md()
-                .border_1()
-                .border_color(cx.theme().colors().border)
-                .bg(cx.theme().colors().elevated_surface_background)
-                .child(
-                    h_flex()
-                        .gap_2()
-                        .child(Icon::new(IconName::Check).color(Color::Success))
-                        .child(v_flex().gap_1().child(Label::new(api_url))),
-                )
-                .child(
-                    Button::new("reset-api-url", "Reset API URL")
-                        .label_size(LabelSize::Small)
-                        .start_icon(Icon::new(IconName::Undo).size(IconSize::Small))
-                        .layer(ElevationIndex::ModalSurface)
-                        .on_click(
-                            cx.listener(|this, _, _window, cx| this.reset_api_url(_window, cx)),
-                        ),
-                )
+            ConfiguredApiCard::new("reset-api-url", api_url)
+                .on_click(cx.listener(|this, _, _window, cx| this.reset_api_url(_window, cx)))
                 .into_any_element()
         } else {
             v_flex()
@@ -862,7 +847,6 @@ impl ConfigurationView {
                     this.save_api_url(cx);
                     cx.notify();
                 }))
-                .gap_2()
                 .child(self.api_url_editor.clone())
                 .into_any_element()
         }
@@ -877,20 +861,10 @@ impl ConfigurationView {
             "API key configured".to_string()
         };
 
-        if !state.api_key_state.has_key() {
-            v_flex()
-                .on_action(cx.listener(Self::save_api_key))
-                .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()
+        let api_key_control = if !state.api_key_state.has_key() {
+            self.api_key_editor.clone().into_any_element()
         } else {
-            ConfiguredApiCard::new(configured_card_label)
+            ConfiguredApiCard::new("lmstudio-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| {
@@ -899,7 +873,20 @@ impl ConfigurationView {
                     ))
                 })
                 .into_any_element()
-        }
+        };
+
+        v_flex()
+            .on_action(cx.listener(Self::save_api_key))
+            .child(api_key_control)
+            .gap_1p5()
+            .mb_2()
+            .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),
+            )
     }
 }
 
@@ -912,39 +899,45 @@ impl Render for ConfigurationView {
             .child(
                 v_flex()
                     .gap_1()
-                    .child(Label::new("Run local LLMs like Llama, Phi, and Qwen."))
+                    .child(Headline::new("LM Studio").size(HeadlineSize::Small))
+                    .child(
+                        Label::new("Run local LLMs like Llama, Phi, and Qwen.").color(Color::Muted),
+                    )
                     .child(
                         List::new()
                             .child(ListBulletItem::new(
                                 "LM Studio needs to be running with at least one model downloaded.",
-                            ))
+                            ).label_color(Color::Muted))
                             .child(
                                 ListBulletItem::new("")
-                                    .child(Label::new("To get your first model, try running"))
-                                    .child(Label::new("lms get qwen2.5-coder-7b").inline_code(cx)),
+                                    .child(Label::new("To get your first model, try running").color(Color::Muted))
+                                    .child(Label::new("lms get qwen2.5-coder-7b").inline_code(cx).color(Color::Muted).ml_1()),
                             ),
                     )
                     .child(Label::new(
                         "Alternatively, you can connect to an LM Studio server by specifying its \
                         URL and API key (may not be required):",
-                    )),
+                    ).color(Color::Muted)),
             )
             .child(self.render_api_url_editor(cx))
             .child(self.render_api_key_editor(cx))
+            .child(Divider::horizontal())
             .child(
                 h_flex()
+                    .pt_2()
                     .w_full()
                     .justify_between()
-                    .gap_2()
+                    .gap_1()
                     .child(
                         h_flex()
                             .w_full()
-                            .gap_2()
+                            .gap_1()
                             .map(|this| {
                                 if is_authenticated {
                                     this.child(
                                         Button::new("lmstudio-site", "LM Studio")
-                                            .style(ButtonStyle::Subtle)
+                                            .style(ButtonStyle::OutlinedGhost)
+                                            .size(ButtonSize::Medium)
                                             .end_icon(
                                                 Icon::new(IconName::ArrowUpRight)
                                                     .size(IconSize::Small)
@@ -961,7 +954,8 @@ impl Render for ConfigurationView {
                                             "download_lmstudio_button",
                                             "Download LM Studio",
                                         )
-                                        .style(ButtonStyle::Subtle)
+                                        .style(ButtonStyle::OutlinedGhost)
+                                        .size(ButtonSize::Medium)
                                         .end_icon(
                                             Icon::new(IconName::ArrowUpRight)
                                                 .size(IconSize::Small)
@@ -976,7 +970,8 @@ impl Render for ConfigurationView {
                             })
                             .child(
                                 Button::new("view-models", "Model Catalog")
-                                    .style(ButtonStyle::Subtle)
+                                    .style(ButtonStyle::OutlinedGhost)
+                                    .size(ButtonSize::Medium)
                                     .end_icon(
                                         Icon::new(IconName::ArrowUpRight)
                                             .size(IconSize::Small)
@@ -991,18 +986,17 @@ impl Render for ConfigurationView {
                         if is_authenticated {
                             this.child(
                                 ButtonLike::new("connected")
-                                    .disabled(true)
-                                    .cursor_style(CursorStyle::Arrow)
+                                    .size(ButtonSize::Medium)
                                     .child(
                                         h_flex()
-                                            .gap_2()
+                                            .gap_1()
                                             .child(Icon::new(IconName::Check).color(Color::Success))
                                             .child(Label::new("Connected"))
-                                            .into_any_element(),
                                     )
                                     .child(
                                         IconButton::new("refresh-models", IconName::RotateCcw)
                                             .tooltip(Tooltip::text("Refresh Models"))
+                                            .icon_size(IconSize::Small)
                                             .on_click(cx.listener(|this, _, _window, cx| {
                                                 this.state.update(cx, |state, _| {
                                                     state.available_models.clear();
@@ -1014,6 +1008,8 @@ impl Render for ConfigurationView {
                         } else {
                             this.child(
                                 Button::new("retry_lmstudio_models", "Connect")
+                                    .style(ButtonStyle::Outlined)
+                                    .size(ButtonSize::Medium)
                                     .start_icon(
                                         Icon::new(IconName::PlayFilled).size(IconSize::XSmall),
                                     )
diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs
index 24afcb669b5cbe..de9f385c8e5284 100644
--- a/crates/language_models/src/provider/mistral.rs
+++ b/crates/language_models/src/provider/mistral.rs
@@ -6,12 +6,12 @@ use futures::{FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream
 use gpui::{AnyView, App, AsyncApp, Context, Entity, Global, SharedString, Task, TaskExt, Window};
 use http_client::{CustomHeaders, HttpClient};
 use language_model::{
-    ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
-    LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
-    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
-    LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
-    LanguageModelToolUse, MessageContent, ProviderConfigurationView, RateLimiter, Role, StopReason,
-    TokenUsage, env_var,
+    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,
 };
 pub use mistral::{MISTRAL_API_URL, StreamResponse};
 pub use settings::MistralAvailableModel as AvailableModel;
@@ -237,28 +237,19 @@ impl LanguageModelProvider for MistralLanguageModelProvider {
             .update(cx, |state, cx| state.set_api_key(None, cx))
     }
 
-    fn configuration_view_v2(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> ProviderConfigurationView {
-        let state = self.state.clone();
-        ProviderConfigurationView::Inline(
-            cx.new(|cx| {
-                crate::ApiKeyEditor::new(
-                    state,
-                    "https://console.mistral.ai/api-keys",
-                    "Paste your Mistral API key",
-                    |state, _cx| crate::api_key_status(&state.api_key_state),
-                    |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)),
-                    |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)),
-                    window,
-                    cx,
-                )
-            })
-            .into(),
-        )
+    fn api_key_configuration(&self, cx: &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(),
+        })
+    }
+
+    fn set_api_key(&self, key: String, cx: &mut App) -> Task> {
+        self.state
+            .update(cx, |state, cx| state.set_api_key(Some(key), cx))
     }
 }
 
@@ -898,7 +889,7 @@ impl Render for ConfigurationView {
                 .size_full()
                 .gap_1()
                 .child(
-                    ConfiguredApiCard::new(configured_card_label)
+                    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| {
diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs
index 43a14c33e875f5..7bbc2ec18cdbe6 100644
--- a/crates/language_models/src/provider/ollama.rs
+++ b/crates/language_models/src/provider/ollama.rs
@@ -4,12 +4,12 @@ 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, CursorStyle, Entity, Task, TaskExt};
+use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt};
 use http_client::{CustomHeaders, HttpClient};
 use language_model::{
-    ApiKeyState, AuthenticateError, DisabledReason, EnvVar, IconOrSvg, LanguageModel,
-    LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName,
-    LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
+    ApiKeyState, AuthenticateError, DisabledReason, EnvVar, IconOrSvg, InlineDescription,
+    LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId,
+    LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
     LanguageModelProviderState, LanguageModelRequest, LanguageModelRequestTool,
     LanguageModelToolChoice, LanguageModelToolUse, LanguageModelToolUseId, MessageContent,
     RateLimiter, Role, StopReason, TokenUsage, env_var,
@@ -25,8 +25,7 @@ use std::pin::Pin;
 use std::sync::Arc;
 use std::sync::LazyLock;
 use ui::{
-    ButtonLike, ButtonLink, ConfiguredApiCard, ElevationIndex, List, ListBulletItem, Tooltip,
-    prelude::*,
+    ButtonLike, ButtonLink, ConfiguredApiCard, Divider, List, ListBulletItem, Tooltip, prelude::*,
 };
 use ui_input::InputField;
 
@@ -277,6 +276,12 @@ 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
@@ -832,31 +837,43 @@ impl ConfigurationView {
     fn render_instructions(cx: &App) -> Div {
         v_flex()
             .gap_2()
-            .child(Label::new(
-                "Run LLMs locally on your machine with Ollama, or connect to an Ollama server. \
+            .child(
+                Label::new(
+                    "Run LLMs locally on your machine with Ollama, or connect to an Ollama server. \
                 Can provide access to Llama, Mistral, Gemma, and hundreds of other models.",
-            ))
-            .child(Label::new("To use local Ollama:"))
+                )
+                .color(Color::Muted),
+            )
+            .child(Label::new("To use local Ollama:").color(Color::Muted))
             .child(
                 List::new()
                     .child(
                         ListBulletItem::new("")
-                            .child(Label::new("Download and install Ollama from"))
+                            .child(
+                                Label::new("Download and install Ollama from").color(Color::Muted),
+                            )
                             .child(ButtonLink::new("ollama.com", "https://ollama.com/download")),
                     )
                     .child(
                         ListBulletItem::new("")
-                            .child(Label::new("Start Ollama and download a model:"))
+                            .child(
+                                Label::new("Start Ollama and download a model:")
+                                    .color(Color::Muted),
+                            )
                             .child(Label::new("ollama run gpt-oss:20b").inline_code(cx)),
                     )
-                    .child(ListBulletItem::new(
-                        "Click 'Connect' below to start using Ollama in Zed",
-                    )),
+                    .child(
+                        ListBulletItem::new("Click 'Connect' below to start using Ollama in Zed")
+                            .label_color(Color::Muted),
+                    ),
             )
-            .child(Label::new(
-                "Alternatively, you can connect to an Ollama server by specifying its \
+            .child(
+                Label::new(
+                    "Alternatively, you can connect to an Ollama server by specifying its \
                 URL and API key (may not be required):",
-            ))
+                )
+                .color(Color::Muted),
+            )
     }
 
     fn render_api_key_editor(&self, cx: &Context) -> impl IntoElement {
@@ -868,27 +885,30 @@ impl ConfigurationView {
             "API key configured".to_string()
         };
 
-        if !state.api_key_state.has_key() {
-            v_flex()
-              .on_action(cx.listener(Self::save_api_key))
-              .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()
+        let api_key_control = if !state.api_key_state.has_key() {
+            self.api_key_editor.clone().into_any_element()
         } else {
-            ConfiguredApiCard::new(configured_card_label)
+            ConfiguredApiCard::new("ollama-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()
-        }
+        };
+
+        v_flex()
+          .on_action(cx.listener(Self::save_api_key))
+          .child(api_key_control)
+          .gap_1p5()
+          .mb_2()
+          .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),
+          )
     }
 
     fn render_context_window_editor(&self, cx: &Context) -> Div {
@@ -897,26 +917,26 @@ impl ConfigurationView {
 
         if custom_context_window_set {
             h_flex()
-                .p_3()
+                .p_1()
                 .justify_between()
                 .rounded_md()
                 .border_1()
-                .border_color(cx.theme().colors().border)
-                .bg(cx.theme().colors().elevated_surface_background)
+                .border_color(cx.theme().colors().border_variant)
+                .bg(cx.theme().colors().background.opacity(0.5))
                 .child(
                     h_flex()
-                        .gap_2()
+                        .gap_1()
                         .child(Icon::new(IconName::Check).color(Color::Success))
-                        .child(v_flex().gap_1().child(Label::new(format!(
+                        .child(Label::new(format!(
                             "Context Window: {}",
                             settings.context_window.unwrap()
-                        )))),
+                        ))),
                 )
                 .child(
                     Button::new("reset-context-window", "Reset")
+                        .style(ButtonStyle::Outlined)
                         .label_size(LabelSize::Small)
                         .start_icon(Icon::new(IconName::Undo).size(IconSize::Small))
-                        .layer(ElevationIndex::ModalSurface)
                         .on_click(
                             cx.listener(|this, _, window, cx| {
                                 this.reset_context_window(window, cx)
@@ -931,6 +951,7 @@ impl ConfigurationView {
                     }),
                 )
                 .child(self.context_window_editor.clone())
+                .gap_1p5()
                 .child(
                     Label::new("Default: Model specific")
                         .size(LabelSize::Small)
@@ -945,23 +966,23 @@ impl ConfigurationView {
 
         if custom_api_url_set {
             h_flex()
-                .p_3()
+                .p_1()
                 .justify_between()
                 .rounded_md()
                 .border_1()
-                .border_color(cx.theme().colors().border)
-                .bg(cx.theme().colors().elevated_surface_background)
+                .border_color(cx.theme().colors().border_variant)
+                .bg(cx.theme().colors().background.opacity(0.5))
                 .child(
                     h_flex()
-                        .gap_2()
+                        .gap_1()
                         .child(Icon::new(IconName::Check).color(Color::Success))
-                        .child(v_flex().gap_1().child(Label::new(api_url))),
+                        .child(Label::new(api_url)),
                 )
                 .child(
                     Button::new("reset-api-url", "Reset API URL")
+                        .style(ButtonStyle::Outlined)
                         .label_size(LabelSize::Small)
                         .start_icon(Icon::new(IconName::Undo).size(IconSize::Small))
-                        .layer(ElevationIndex::ModalSurface)
                         .on_click(
                             cx.listener(|this, _, window, cx| this.reset_api_url(window, cx)),
                         ),
@@ -984,15 +1005,18 @@ impl Render for ConfigurationView {
 
         v_flex()
             .gap_2()
+            .child(Headline::new("Ollama").size(HeadlineSize::Small))
             .child(Self::render_instructions(cx))
             .child(self.render_api_url_editor(cx))
             .child(self.render_context_window_editor(cx))
             .child(self.render_api_key_editor(cx))
+            .child(Divider::horizontal())
             .child(
                 h_flex()
+                    .pt_2()
                     .w_full()
                     .justify_between()
-                    .gap_2()
+                    .gap_1()
                     .child(
                         h_flex()
                             .w_full()
@@ -1001,7 +1025,8 @@ impl Render for ConfigurationView {
                                 if is_authenticated {
                                     this.child(
                                         Button::new("ollama-site", "Ollama")
-                                            .style(ButtonStyle::Subtle)
+                                            .style(ButtonStyle::OutlinedGhost)
+                                            .size(ButtonSize::Medium)
                                             .end_icon(
                                                 Icon::new(IconName::ArrowUpRight)
                                                     .size(IconSize::XSmall)
@@ -1013,7 +1038,8 @@ impl Render for ConfigurationView {
                                 } else {
                                     this.child(
                                         Button::new("download_ollama_button", "Download Ollama")
-                                            .style(ButtonStyle::Subtle)
+                                            .style(ButtonStyle::OutlinedGhost)
+                                            .size(ButtonSize::Medium)
                                             .end_icon(
                                                 Icon::new(IconName::ArrowUpRight)
                                                     .size(IconSize::XSmall)
@@ -1028,7 +1054,8 @@ impl Render for ConfigurationView {
                             })
                             .child(
                                 Button::new("view-models", "View All Models")
-                                    .style(ButtonStyle::Subtle)
+                                    .style(ButtonStyle::OutlinedGhost)
+                                    .size(ButtonSize::Medium)
                                     .end_icon(
                                         Icon::new(IconName::ArrowUpRight)
                                             .size(IconSize::XSmall)
@@ -1041,17 +1068,16 @@ impl Render for ConfigurationView {
                         if is_authenticated {
                             this.child(
                                 ButtonLike::new("connected")
-                                    .disabled(true)
-                                    .cursor_style(CursorStyle::Arrow)
+                                    .size(ButtonSize::Medium)
                                     .child(
                                         h_flex()
-                                            .gap_2()
+                                            .gap_1()
                                             .child(Icon::new(IconName::Check).color(Color::Success))
-                                            .child(Label::new("Connected"))
-                                            .into_any_element(),
+                                            .child(Label::new("Connected")),
                                     )
                                     .child(
                                         IconButton::new("refresh-models", IconName::RotateCcw)
+                                            .icon_size(IconSize::Small)
                                             .tooltip(Tooltip::text("Refresh Models"))
                                             .on_click(cx.listener(|this, _, window, cx| {
                                                 this.state.update(cx, |state, _| {
@@ -1064,6 +1090,8 @@ impl Render for ConfigurationView {
                         } else {
                             this.child(
                                 Button::new("retry_ollama_models", "Connect")
+                                    .style(ButtonStyle::Outlined)
+                                    .size(ButtonSize::Medium)
                                     .start_icon(
                                         Icon::new(IconName::PlayOutlined).size(IconSize::XSmall),
                                     )
diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs
index a67a8741fcb15d..87be0e2751b867 100644
--- a/crates/language_models/src/provider/open_ai.rs
+++ b/crates/language_models/src/provider/open_ai.rs
@@ -5,11 +5,11 @@ use futures::{FutureExt, StreamExt, future::BoxFuture};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window};
 use http_client::{CustomHeaders, HttpClient};
 use language_model::{
-    ApiKeyState, AuthenticateError, EnvVar, FastModeConfirmation, IconOrSvg, LanguageModel,
-    LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel,
-    LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
-    LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
-    LanguageModelToolChoice, OPEN_AI_PROVIDER_ID, OPEN_AI_PROVIDER_NAME, ProviderConfigurationView,
+    ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, FastModeConfirmation, IconOrSvg,
+    LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
+    LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProvider,
+    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
+    LanguageModelRequest, LanguageModelToolChoice, OPEN_AI_PROVIDER_ID, OPEN_AI_PROVIDER_NAME,
     RateLimiter, env_var,
 };
 use menu;
@@ -219,28 +219,19 @@ impl LanguageModelProvider for OpenAiLanguageModelProvider {
             .update(cx, |state, cx| state.set_api_key(None, cx))
     }
 
-    fn configuration_view_v2(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> ProviderConfigurationView {
-        let state = self.state.clone();
-        ProviderConfigurationView::Inline(
-            cx.new(|cx| {
-                crate::ApiKeyEditor::new(
-                    state,
-                    "https://platform.openai.com/api-keys",
-                    "sk-...",
-                    |state, _cx| crate::api_key_status(&state.api_key_state),
-                    |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)),
-                    |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)),
-                    window,
-                    cx,
-                )
-            })
-            .into(),
-        )
+    fn api_key_configuration(&self, cx: &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(),
+        })
+    }
+
+    fn set_api_key(&self, key: String, cx: &mut App) -> Task> {
+        self.state
+            .update(cx, |state, cx| state.set_api_key(Some(key), cx))
     }
 
     fn fast_mode_confirmation(&self, _cx: &App) -> Option {
@@ -728,7 +719,7 @@ impl Render for ConfigurationView {
                 )
                 .into_any_element()
         } else {
-            ConfiguredApiCard::new(configured_card_label)
+            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| {
diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs
index 979022a8878ee6..f41e04972d0594 100644
--- a/crates/language_models/src/provider/open_router.rs
+++ b/crates/language_models/src/provider/open_router.rs
@@ -5,12 +5,12 @@ use futures::{FutureExt, Stream, StreamExt, future::BoxFuture};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt};
 use http_client::{CustomHeaders, HttpClient};
 use language_model::{
-    ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
-    LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
-    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
-    LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
-    LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, ProviderConfigurationView,
-    RateLimiter, Role, StopReason, TokenUsage, env_var,
+    ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel,
+    LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName,
+    LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
+    LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
+    LanguageModelToolResultContent, LanguageModelToolSchemaFormat, LanguageModelToolUse,
+    MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var,
 };
 use open_router::{
     Model, ModelMode as OpenRouterModelMode, OPEN_ROUTER_API_URL, ResponseStreamEvent, list_models,
@@ -274,28 +274,19 @@ impl LanguageModelProvider for OpenRouterLanguageModelProvider {
             .update(cx, |state, cx| state.set_api_key(None, cx))
     }
 
-    fn configuration_view_v2(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> ProviderConfigurationView {
-        let state = self.state.clone();
-        ProviderConfigurationView::Inline(
-            cx.new(|cx| {
-                crate::ApiKeyEditor::new(
-                    state,
-                    "https://openrouter.ai/keys",
-                    "sk-or-...",
-                    |state, _cx| crate::api_key_status(&state.api_key_state),
-                    |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)),
-                    |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)),
-                    window,
-                    cx,
-                )
-            })
-            .into(),
-        )
+    fn api_key_configuration(&self, cx: &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(),
+        })
+    }
+
+    fn set_api_key(&self, key: String, cx: &mut App) -> Task> {
+        self.state
+            .update(cx, |state, cx| state.set_api_key(Some(key), cx))
     }
 }
 
@@ -1019,7 +1010,7 @@ impl Render for ConfigurationView {
                 )
                 .into_any_element()
         } else {
-            ConfiguredApiCard::new(configured_card_label)
+            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| {
diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs
index f63268e60e242a..2b09032ca515f2 100644
--- a/crates/language_models/src/provider/openai_subscribed.rs
+++ b/crates/language_models/src/provider/openai_subscribed.rs
@@ -9,11 +9,11 @@ use http_client::{
     http::{HeaderName, HeaderValue},
 };
 use language_model::{
-    AuthenticateError, FastModeConfirmation, IconOrSvg, LanguageModel,
+    AuthenticateError, FastModeConfirmation, IconOrSvg, InlineDescription, LanguageModel,
     LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel,
     LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
     LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
-    LanguageModelToolChoice, RateLimiter,
+    LanguageModelToolChoice, ProviderConfigurationView, RateLimiter,
 };
 use open_ai::{ReasoningEffort, responses::stream_response};
 use rand::RngCore as _;
@@ -31,6 +31,9 @@ const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("opena
 const PROVIDER_NAME: LanguageModelProviderName =
     LanguageModelProviderName::new("ChatGPT Subscription");
 
+const SUBSCRIPTION_DESCRIPTION: &str =
+    "Sign in with your ChatGPT Plus or Pro subscription to use OpenAI models in Zed's agent.";
+
 const CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex";
 const OPENAI_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
 const OPENAI_AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize";
@@ -248,8 +251,48 @@ impl LanguageModelProvider for OpenAiSubscribedProvider {
     ) -> AnyView {
         let state = self.state.clone();
         let http_client = self.http_client.clone();
-        cx.new(|_cx| ConfigurationView { state, http_client })
-            .into()
+        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() {
+            None
+        } else {
+            Some("Configure ChatGPT".into())
+        }
+    }
+
+    fn inline_description(&self, cx: &App) -> Option {
+        if self.state.read(cx).is_authenticated() {
+            None
+        } else {
+            Some(InlineDescription::Text(SUBSCRIPTION_DESCRIPTION.into()))
+        }
     }
 
     fn reset_credentials(&self, cx: &mut App) -> Task> {
@@ -1043,6 +1086,9 @@ fn do_sign_out(state: &gpui::WeakEntity, cx: &mut App) -> Task
 struct ConfigurationView {
     state: Entity,
     http_client: Arc,
+    /// When `true`, the description is rendered elsewhere (the settings row's
+    /// left column), so it's omitted here to avoid duplication.
+    compact: bool,
 }
 
 impl Render for ConfigurationView {
@@ -1059,7 +1105,7 @@ impl Render for ConfigurationView {
 
             return v_flex()
                 .child(
-                    ConfiguredApiCard::new(SharedString::from(label))
+                    ConfiguredApiCard::new("openai-subscribed-sign-out", SharedString::from(label))
                         .button_label("Sign Out")
                         .on_click(cx.listener(move |_this, _, _window, cx| {
                             do_sign_out(&weak_state, cx).detach_and_log_err(cx);
@@ -1076,27 +1122,21 @@ impl Render for ConfigurationView {
         let button_label = if is_signing_in {
             "Signing in…"
         } else {
-            "Sign in to use ChatGPT Subscription"
+            "Sign In"
         };
 
         v_flex()
             .gap_2()
-            .child(Label::new(
-                "Sign in with your ChatGPT Plus or Pro subscription to use OpenAI models in Zed's agent.",
-            ))
+            .when(!self.compact, |this| {
+                this.child(Label::new(SUBSCRIPTION_DESCRIPTION))
+            })
             .child(
                 Button::new("sign-in", button_label)
-                    .full_width()
+                    .when(!self.compact, |this| this.full_width())
                     .style(ButtonStyle::Outlined)
+                    .size(ButtonSize::Medium)
                     .loading(is_signing_in)
                     .disabled(is_signing_in)
-                    .when(!is_signing_in, |this| {
-                        this.start_icon(
-                            Icon::new(IconName::AiOpenAi)
-                                .size(IconSize::Small)
-                                .color(Color::Muted),
-                        )
-                    })
                     .on_click(move |_, _window, cx| {
                         do_sign_in(&provider_state, &http_client, cx);
                     }),
diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs
index 9a5dbcdfccb40a..f75c71e425b9a8 100644
--- a/crates/language_models/src/provider/opencode.rs
+++ b/crates/language_models/src/provider/opencode.rs
@@ -6,11 +6,11 @@ use futures::{FutureExt, StreamExt, future::BoxFuture};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window};
 use http_client::{AsyncBody, CustomHeaders, HttpClient, http};
 use language_model::{
-    ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
-    LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName,
-    LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
-    LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, RateLimiter,
-    ReasoningEffort, env_var,
+    ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, InlineDescription, LanguageModel,
+    LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel,
+    LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
+    LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
+    LanguageModelToolChoice, RateLimiter, ReasoningEffort, env_var,
 };
 use opencode::{ApiProtocol, OPENCODE_API_URL, OpenCodeSubscription};
 pub use settings::OpenCodeAvailableModel as AvailableModel;
@@ -18,7 +18,7 @@ use settings::{Settings, SettingsStore, update_settings_file};
 use std::sync::{Arc, LazyLock};
 use strum::IntoEnumIterator;
 use ui::{
-    Banner, ButtonLink, ConfiguredApiCard, List, ListBulletItem, Severity, Switch,
+    Banner, ButtonLink, ConfiguredApiCard, Divider, List, ListBulletItem, Severity, Switch,
     SwitchLabelPosition, ToggleState, prelude::*,
 };
 use ui_input::InputField;
@@ -200,6 +200,12 @@ 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
@@ -859,37 +865,12 @@ impl Render for ConfigurationView {
             }
         };
 
-        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 OpenCode models in Zed, you need an API key:",
-                ))
-                .child(
-                    List::new()
-                        .child(
-                            ListBulletItem::new("")
-                                .child(Label::new("Sign in and get your key at"))
-                                .child(ButtonLink::new(
-                                    "OpenCode Console",
-                                    "https://opencode.ai/auth",
-                                )),
-                        )
-                        .child(ListBulletItem::new(
-                            "Paste your API key below and hit enter to start using OpenCode",
-                        )),
-                )
-                .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()
+        let is_editing = self.should_render_editor(cx);
+
+        let api_key_control = if is_editing {
+            self.api_key_editor.clone().into_any_element()
         } else {
-            ConfiguredApiCard::new(configured_card_label)
+            ConfiguredApiCard::new("opencode-reset-key", configured_card_label)
                 .disabled(env_var_set)
                 .when(env_var_set, |this| {
                     this.tooltip_label(format!(
@@ -900,8 +881,39 @@ impl Render for ConfigurationView {
                 .into_any_element()
         };
 
+        let api_key_section = v_flex()
+            .on_action(cx.listener(Self::save_api_key))
+            .child(Label::new(
+                "To use OpenCode models in Zed, you need an API key:",
+            ).color(Color::Muted))
+            .child(
+                List::new()
+                    .child(
+                        ListBulletItem::new("")
+                            .child(Label::new("Sign in and get your key at").color(Color::Muted))
+                            .child(ButtonLink::new(
+                                "OpenCode Console",
+                                "https://opencode.ai/auth",
+                            )),
+                    )
+                    .when(is_editing, |this| {
+                        this.child(ListBulletItem::new(
+                            "Paste your API key below and hit enter to start using OpenCode",
+                        ).label_color(Color::Muted))
+                    }),
+            )
+            .child(api_key_control)
+            .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_1p5(),
+            )
+            .into_any_element();
+
         if self.load_credentials_task.is_some() {
-            div().child(Label::new("Loading credentials...")).into_any()
+            Label::new("Loading Credentials…").into_any_element()
         } else {
             let settings = OpenCodeLanguageModelProvider::settings(cx);
             let show_zen = settings.show_zen_models;
@@ -909,12 +921,13 @@ impl Render for ConfigurationView {
             let show_free = settings.show_free_models;
 
             let subscription_toggles = v_flex()
-                .gap_1()
-                .child(Label::new("Subscriptions:").color(Color::Muted))
+                .gap_2()
+                .child(Label::new("Subscriptions"))
                 .child(
                     Switch::new("opencode-show-zen-models", show_zen.into())
-                        .label("Show Zen models")
-                        .label_position(SwitchLabelPosition::End)
+                        .full_width(true)
+                        .label("Show Zen Models")
+                        .label_position(SwitchLabelPosition::Start)
                         .on_click(cx.listener(|this, state, window, cx| {
                             this.set_subscription_enabled(
                                 OpenCodeSubscription::Zen,
@@ -924,10 +937,12 @@ impl Render for ConfigurationView {
                             );
                         })),
                 )
+                .child(Divider::horizontal_dashed())
                 .child(
                     Switch::new("opencode-show-go-models", show_go.into())
+                        .full_width(true)
                         .label("Show Go models")
-                        .label_position(SwitchLabelPosition::End)
+                        .label_position(SwitchLabelPosition::Start)
                         .on_click(cx.listener(|this, state, window, cx| {
                             this.set_subscription_enabled(
                                 OpenCodeSubscription::Go,
@@ -937,10 +952,12 @@ impl Render for ConfigurationView {
                             );
                         })),
                 )
+                .child(Divider::horizontal_dashed())
                 .child(
                     Switch::new("opencode-show-free-models", show_free.into())
+                        .full_width(true)
                         .label("Show Free models")
-                        .label_position(SwitchLabelPosition::End)
+                        .label_position(SwitchLabelPosition::Start)
                         .on_click(cx.listener(|this, state, window, cx| {
                             this.set_subscription_enabled(
                                 OpenCodeSubscription::Free,
@@ -961,8 +978,10 @@ impl Render for ConfigurationView {
 
             v_flex()
                 .size_full()
-                .gap_2()
+                .gap_2p5()
+                .child(Headline::new("OpenCode").size(HeadlineSize::Small))
                 .child(api_key_section)
+                .child(Divider::horizontal())
                 .child(subscription_toggles)
                 .children(no_subscriptions_warning)
                 .into_any()
diff --git a/crates/language_models/src/provider/vercel_ai_gateway.rs b/crates/language_models/src/provider/vercel_ai_gateway.rs
index d319538bc431c3..dcceffe22e7f0f 100644
--- a/crates/language_models/src/provider/vercel_ai_gateway.rs
+++ b/crates/language_models/src/provider/vercel_ai_gateway.rs
@@ -7,11 +7,11 @@ use http_client::{
     AsyncBody, CustomHeaders, HttpClient, Method, Request as HttpRequest, RequestBuilderExt, http,
 };
 use language_model::{
-    ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
-    LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
-    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
-    LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat,
-    ProviderConfigurationView, RateLimiter, env_var,
+    ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel,
+    LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName,
+    LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
+    LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
+    LanguageModelToolSchemaFormat, RateLimiter, env_var,
 };
 use open_ai::ResponseStreamEvent;
 use serde::Deserialize;
@@ -261,28 +261,21 @@ impl LanguageModelProvider for VercelAiGatewayLanguageModelProvider {
             .update(cx, |state, cx| state.set_api_key(None, cx))
     }
 
-    fn configuration_view_v2(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> ProviderConfigurationView {
-        let state = self.state.clone();
-        ProviderConfigurationView::Inline(
-            cx.new(|cx| {
-                crate::ApiKeyEditor::new(
-                    state,
-                    "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys&title=Go+to+AI+Gateway",
-                    "Paste your Vercel AI Gateway API key",
-                    |state, _cx| crate::api_key_status(&state.api_key_state),
-                    |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)),
-                    |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)),
-                    window,
-                    cx,
-                )
-            })
-            .into(),
-        )
+    fn api_key_configuration(&self, cx: &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(),
+        })
+    }
+
+    fn set_api_key(&self, key: String, cx: &mut App) -> Task> {
+        self.state
+            .update(cx, |state, cx| state.set_api_key(Some(key), cx))
     }
 }
 
@@ -743,7 +736,7 @@ impl Render for ConfigurationView {
                 )
                 .into_any_element()
         } else {
-            ConfiguredApiCard::new(configured_card_label)
+            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."))
diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs
index 1d21464c5ffbf9..92121f2e5ff442 100644
--- a/crates/language_models/src/provider/x_ai.rs
+++ b/crates/language_models/src/provider/x_ai.rs
@@ -5,11 +5,11 @@ use futures::{FutureExt, StreamExt, future::BoxFuture};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt, Window};
 use http_client::{CustomHeaders, HttpClient};
 use language_model::{
-    ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
-    LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName,
-    LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
-    LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
-    LanguageModelToolSchemaFormat, ProviderConfigurationView, RateLimiter, env_var,
+    ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel,
+    LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel,
+    LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
+    LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
+    LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, env_var,
 };
 use open_ai::ResponseStreamEvent;
 pub use settings::XaiAvailableModel as AvailableModel;
@@ -208,28 +208,19 @@ impl LanguageModelProvider for XAiLanguageModelProvider {
             .update(cx, |state, cx| state.set_api_key(None, cx))
     }
 
-    fn configuration_view_v2(
-        &self,
-        _target_agent: language_model::ConfigurationViewTargetAgent,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> ProviderConfigurationView {
-        let state = self.state.clone();
-        ProviderConfigurationView::Inline(
-            cx.new(|cx| {
-                crate::ApiKeyEditor::new(
-                    state,
-                    "https://console.x.ai/team/default/api-keys",
-                    "xai-...",
-                    |state, _cx| crate::api_key_status(&state.api_key_state),
-                    |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)),
-                    |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)),
-                    window,
-                    cx,
-                )
-            })
-            .into(),
-        )
+    fn api_key_configuration(&self, cx: &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(),
+        })
+    }
+
+    fn set_api_key(&self, key: String, cx: &mut App) -> Task> {
+        self.state
+            .update(cx, |state, cx| state.set_api_key(Some(key), cx))
     }
 }
 
@@ -632,7 +623,7 @@ impl Render for ConfigurationView {
                 )
                 .into_any_element()
         } else {
-            ConfiguredApiCard::new(configured_card_label)
+            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."))
diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs
index 4d1101d7186ef5..34ee4440729a93 100644
--- a/crates/settings_ui/src/page_data.rs
+++ b/crates/settings_ui/src/page_data.rs
@@ -7943,7 +7943,7 @@ fn collaboration_page() -> SettingsPage {
 }
 
 fn ai_page(cx: &App) -> SettingsPage {
-    fn general_section() -> [SettingsPageItem; 3] {
+    fn general_section() -> [SettingsPageItem; 6] {
         [
             SettingsPageItem::SectionHeader("General"),
             SettingsPageItem::SettingItem(SettingItem {
@@ -7974,24 +7974,11 @@ fn ai_page(cx: &App) -> SettingsPage {
                 metadata: None,
                 files: USER,
             }),
-        ]
-    }
-
-    fn agent_configuration_section(cx: &App) -> Box<[SettingsPageItem]> {
-        use feature_flags::FeatureFlagAppExt as _;
-
-        // The LLM provider and MCP server pages are gated behind a feature flag
-        // while their configuration is being moved out of the agent panel.
-        let agent_settings_ui_enabled = cx.has_flag::();
-
-        let mut items = vec![SettingsPageItem::SectionHeader("Agent Configuration")];
-
-        if agent_settings_ui_enabled {
-            items.push(SettingsPageItem::SubPageLink(SubPageLink {
+            SettingsPageItem::SubPageLink(SubPageLink {
                 title: "LLM Providers".into(),
                 r#type: Default::default(),
                 json_path: Some("llm_providers"),
-                description: Some("Configure API keys and settings for LLM providers.".into()),
+                description: Some("Configure natively-included model providers.".into()),
                 search_aliases: &[
                     "ai",
                     "amazon",
@@ -8021,8 +8008,52 @@ fn ai_page(cx: &App) -> SettingsPage {
                 in_json: false,
                 files: USER,
                 render: render_llm_providers_page,
-            }));
-        }
+            }),
+            SettingsPageItem::SubPageLink(SubPageLink {
+                title: "External Agents".into(),
+                r#type: Default::default(),
+                json_path: Some("agent_servers"),
+                description: Some(
+                    "View, add, and remove agents connected through the Agent Client Protocol."
+                        .into(),
+                ),
+                search_aliases: &[
+                    "acp",
+                    "agent client protocol",
+                    "amp",
+                    "claude agent",
+                    "claude code",
+                    "codex",
+                    "copilot cli",
+                    "cursor",
+                    "external agent",
+                    "factory droid",
+                    "github copilot",
+                    "grok build",
+                    "junie",
+                    "opencode",
+                ],
+                in_json: false,
+                files: USER,
+                render: render_external_agents_page,
+            }),
+            SettingsPageItem::SubPageLink(SubPageLink {
+                title: "MCP Servers".into(),
+                r#type: Default::default(),
+                json_path: Some("context_servers"),
+                description: Some(
+                    "View, add, configure, and remove Model Context Protocol servers.".into(),
+                ),
+                search_aliases: &["context server", "mcp", "model context protocol"],
+                in_json: false,
+                files: USER,
+                render: render_mcp_servers_page,
+            }),
+        ]
+    }
+
+    fn agent_configuration_section(_cx: &App) -> Box<[SettingsPageItem]> {
+        let mut items = vec![SettingsPageItem::SectionHeader("Agent Configuration")];
 
         items.extend([
             SettingsPageItem::SubPageLink(SubPageLink {
@@ -8068,49 +8099,6 @@ fn ai_page(cx: &App) -> SettingsPage {
             }),
         ]);
 
-        if agent_settings_ui_enabled {
-            items.push(SettingsPageItem::SubPageLink(SubPageLink {
-                title: "MCP Servers".into(),
-                r#type: Default::default(),
-                json_path: Some("context_servers"),
-                description: Some(
-                    "View, add, configure, and remove Model Context Protocol servers.".into(),
-                ),
-                search_aliases: &["context server", "mcp", "model context protocol"],
-                in_json: false,
-                files: USER,
-                render: render_mcp_servers_page,
-            }));
-            items.push(SettingsPageItem::SubPageLink(SubPageLink {
-                title: "External Agents".into(),
-                r#type: Default::default(),
-                json_path: Some("agent_servers"),
-                description: Some(
-                    "View, add, and remove agents connected through the Agent Client Protocol."
-                        .into(),
-                ),
-                search_aliases: &[
-                    "acp",
-                    "agent client protocol",
-                    "amp",
-                    "claude agent",
-                    "claude code",
-                    "codex",
-                    "copilot cli",
-                    "cursor",
-                    "external agent",
-                    "factory droid",
-                    "github copilot",
-                    "grok build",
-                    "junie",
-                    "opencode",
-                ],
-                in_json: false,
-                files: USER,
-                render: render_external_agents_page,
-            }));
-        }
-
         items.extend([
             SettingsPageItem::SettingItem(SettingItem {
                 title: "Single File Review",
@@ -8467,28 +8455,6 @@ fn ai_page(cx: &App) -> SettingsPage {
         items.into_boxed_slice()
     }
 
-    fn context_servers_section() -> [SettingsPageItem; 2] {
-        [
-            SettingsPageItem::SectionHeader("Context Servers"),
-            SettingsPageItem::SettingItem(SettingItem {
-                title: "Context Server Timeout",
-                description: "Default timeout in seconds for context server tool calls. Can be overridden per-server in context_servers configuration.",
-                field: Box::new(SettingField {
-                    organization_override: None,
-                    json_path: Some("context_server_timeout"),
-                    pick: |settings_content| {
-                        settings_content.project.context_server_timeout.as_ref()
-                    },
-                    write: |settings_content, value, _| {
-                        settings_content.project.context_server_timeout = value;
-                    },
-                }),
-                metadata: None,
-                files: USER | PROJECT,
-            }),
-        ]
-    }
-
     fn edit_prediction_display_sub_section() -> [SettingsPageItem; 1] {
         [SettingsPageItem::SettingItem(SettingItem {
             title: "Display Mode",
@@ -8519,30 +8485,16 @@ fn ai_page(cx: &App) -> SettingsPage {
         })]
     }
 
-    use feature_flags::FeatureFlagAppExt as _;
-
-    // When the agent settings UI is enabled, the context server timeout is shown
-    // inside the MCP Servers sub-page. Otherwise it remains a standalone section
-    // here so it stays reachable.
-    let agent_settings_ui_enabled = cx.has_flag::();
-
-    let mut items = concat_sections!(
-        @vec,
-        general_section(),
-        agent_configuration_section(cx),
-    );
-    if !agent_settings_ui_enabled {
-        items.extend(context_servers_section());
-    }
-    items.extend(concat_sections!(
-        @vec,
-        edit_prediction_language_settings_section(),
-        edit_prediction_display_sub_section(),
-    ));
-
     SettingsPage {
         title: "AI",
-        items: items.into(),
+        items: concat_sections!(
+            @vec,
+            general_section(),
+            agent_configuration_section(cx),
+            edit_prediction_language_settings_section(),
+            edit_prediction_display_sub_section(),
+        )
+        .into(),
     }
 }
 
diff --git a/crates/settings_ui/src/pages.rs b/crates/settings_ui/src/pages.rs
index 1287f36f064f1a..407343365ea711 100644
--- a/crates/settings_ui/src/pages.rs
+++ b/crates/settings_ui/src/pages.rs
@@ -15,10 +15,14 @@ pub(crate) use audio_input_output_setup::{
 };
 pub(crate) use audio_test_window::open_audio_test_window;
 pub(crate) use edit_prediction_provider_setup::render_edit_prediction_setup_page;
-pub(crate) use external_agents_page::{CustomAgentForm, render_external_agents_page};
+pub(crate) use external_agents_page::{
+    CustomAgentForm, render_add_agent_popover, render_external_agents_page,
+};
 pub(crate) use feature_flags::render_feature_flags_page;
 pub(crate) use llm_providers_page::render_llm_providers_page;
-pub(crate) use mcp_servers_page::{McpServerForm, render_mcp_servers_page};
+pub(crate) use mcp_servers_page::{
+    McpServerForm, render_add_server_popover, render_mcp_servers_page,
+};
 pub(crate) use sandbox_settings::render_sandbox_settings_page;
 pub use skill_creator::SkillCreatorOpenMode;
 pub(crate) use skill_creator::{
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 0f7b9d512fe9da..3f756a876a862a 100644
--- a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs
+++ b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs
@@ -230,10 +230,11 @@ fn render_api_key_provider(
     };
 
     let base_container = v_flex().id(title).min_w_0().pt_8().gap_1p5();
+
     let header = SettingsSectionHeader::new(title)
         .icon(icon)
         .no_padding(true);
-    let button_link_label = format!("{} dashboard", title);
+
     let description = match docs {
         ApiKeyDocs::Custom { message } => div().min_w_0().w_full().child(
             Label::new(message)
@@ -251,7 +252,7 @@ fn render_api_key_provider(
                     .color(Color::Muted),
             )
             .child(
-                ButtonLink::new(button_link_label, dashboard_url)
+                ButtonLink::new(format!("{title} dashboard"), dashboard_url)
                     .no_icon(true)
                     .label_size(LabelSize::Small)
                     .label_color(Color::Muted),
@@ -262,6 +263,7 @@ fn render_api_key_provider(
                     .color(Color::Muted),
             ),
     };
+
     let configured_card_label = if is_from_env_var {
         "API Key Set in Environment Variable"
     } else {
@@ -270,7 +272,7 @@ fn render_api_key_provider(
 
     let container = if has_key {
         base_container.child(header).child(
-            ConfiguredApiCard::new(configured_card_label)
+            ConfiguredApiCard::new(format!("{title}-reset-key"), configured_card_label)
                 .button_label("Reset Key")
                 .button_tab_index(0)
                 .disabled(is_from_env_var)
@@ -298,6 +300,7 @@ fn render_api_key_provider(
                         .w_full()
                         .min_w_0()
                         .max_w_1_2()
+                        .gap_0p5()
                         .child(Label::new("API Key"))
                         .child(description)
                         .when_some(env_var_name, |this, env_var_name| {
diff --git a/crates/settings_ui/src/pages/external_agents_page.rs b/crates/settings_ui/src/pages/external_agents_page.rs
index f05937680ed80b..b4b4350619dba8 100644
--- a/crates/settings_ui/src/pages/external_agents_page.rs
+++ b/crates/settings_ui/src/pages/external_agents_page.rs
@@ -14,7 +14,7 @@ use settings::{
 };
 use ui::{
     AiSettingItem, AiSettingItemSource, AiSettingItemStatus, ContextMenu, ContextMenuEntry,
-    Divider, DividerColor, PopoverMenu, Tooltip, prelude::*,
+    Divider, PopoverMenu, Tooltip, prelude::*,
 };
 use util::ResultExt as _;
 use workspace::{MultiWorkspace, Workspace, create_and_open_local_file};
@@ -25,7 +25,7 @@ use crate::SettingsWindow;
 pub(crate) fn render_external_agents_page(
     settings_window: &SettingsWindow,
     scroll_handle: &ScrollHandle,
-    window: &mut Window,
+    _window: &mut Window,
     cx: &mut Context,
 ) -> AnyElement {
     let agent_server_store = get_agent_server_store(settings_window, cx);
@@ -41,8 +41,6 @@ pub(crate) fn render_external_agents_page(
         render_no_project_state(cx)
     };
 
-    let add_agent_popover = render_add_agent_popover(settings_window, window, cx);
-
     v_flex()
         .id("external-agents-page")
         .size_full()
@@ -51,22 +49,11 @@ pub(crate) fn render_external_agents_page(
         .pb_16()
         .track_scroll(scroll_handle)
         .overflow_y_scroll()
+        .child(Label::new("External Agents"))
         .child(
-            h_flex()
-                .w_full()
-                .justify_between()
-                .items_center()
-                .mb_4()
-                .child(
-                    v_flex()
-                        .child(Label::new("External Agents").size(LabelSize::Large))
-                        .child(
-                            Label::new("All agents connected through the Agent Client Protocol.")
-                                .size(LabelSize::Small)
-                                .color(Color::Muted),
-                        ),
-                )
-                .child(add_agent_popover),
+            Label::new("Agents connected through the Agent Client Protocol.")
+                .size(LabelSize::Small)
+                .color(Color::Muted),
         )
         .child(agent_list)
         .into_any_element()
@@ -165,11 +152,7 @@ fn render_agent_list(agents: Vec, cx: &mut Context) ->
             agents.into_iter().map(|(id, icon, display_name, source)| {
                 render_agent(id, icon, display_name, source, cx).into_any_element()
             }),
-            || {
-                Divider::horizontal()
-                    .color(DividerColor::BorderFaded)
-                    .into_any_element()
-            },
+            || Divider::horizontal().into_any_element(),
         ))
         .into_any_element()
 }
@@ -198,22 +181,20 @@ fn render_agent(
     // Only custom agents are editable here; registry agents are managed via the
     // ACP registry and only support removal.
     let configure_button = (source == ExternalAgentSource::Custom).then(|| {
-        IconButton::new(
-            SharedString::from(format!("configure-{}", id_string)),
-            IconName::Settings,
-        )
-        .icon_color(Color::Muted)
-        .icon_size(IconSize::Small)
-        .tab_index(0isize)
-        .tooltip(Tooltip::text("Configure Agent"))
-        .on_click(cx.listener({
-            let id = id.clone();
-            move |this, _event, window, cx| {
-                let existing =
-                    custom_agent_settings(&id, cx).map(|settings| (id.clone(), settings));
-                open_custom_agent_form(this, existing, window, cx);
-            }
-        }))
+        IconButton::new(format!("configure-{}", id_string), IconName::Settings)
+            .icon_color(Color::Muted)
+            .icon_size(IconSize::Small)
+            .size(ButtonSize::Medium)
+            .tab_index(0isize)
+            .tooltip(Tooltip::text("Configure Agent"))
+            .on_click(cx.listener({
+                let id = id.clone();
+                move |this, _event, window, cx| {
+                    let existing =
+                        custom_agent_settings(&id, cx).map(|settings| (id.clone(), settings));
+                    open_custom_agent_form(this, existing, window, cx);
+                }
+            }))
     });
 
     let remove_tooltip = match source {
@@ -221,17 +202,15 @@ fn render_agent(
         ExternalAgentSource::Custom => "Remove Custom Agent",
     };
 
-    let remove_button = IconButton::new(
-        SharedString::from(format!("uninstall-{}", id_string)),
-        IconName::Trash,
-    )
-    .icon_color(Color::Muted)
-    .icon_size(IconSize::Small)
-    .tab_index(0isize)
-    .tooltip(Tooltip::text(remove_tooltip))
-    .on_click(move |_event, _window, cx| {
-        remove_agent(&id, source, cx);
-    });
+    let remove_button = IconButton::new(format!("uninstall-{}", id_string), IconName::Trash)
+        .icon_color(Color::Muted)
+        .icon_size(IconSize::Small)
+        .size(ButtonSize::Medium)
+        .tab_index(0isize)
+        .tooltip(Tooltip::text(remove_tooltip))
+        .on_click(move |_event, _window, cx| {
+            remove_agent(&id, source, cx);
+        });
 
     // The connection status of an external agent is tracked per agent-panel
     // session (via the agent panel's `AgentConnectionStore`), which isn't
@@ -273,7 +252,7 @@ fn remove_agent(id: &AgentId, source: ExternalAgentSource, cx: &mut App) {
     });
 }
 
-fn render_add_agent_popover(
+pub(crate) fn render_add_agent_popover(
     settings_window: &SettingsWindow,
     window: &mut Window,
     cx: &mut Context,
diff --git a/crates/settings_ui/src/pages/llm_providers_page.rs b/crates/settings_ui/src/pages/llm_providers_page.rs
index d663dbe7d947c3..cb0b0a95b994a0 100644
--- a/crates/settings_ui/src/pages/llm_providers_page.rs
+++ b/crates/settings_ui/src/pages/llm_providers_page.rs
@@ -1,13 +1,15 @@
 use std::sync::Arc;
 
-use gpui::{ScrollHandle, prelude::*};
+use gpui::{AnyView, ScrollHandle, prelude::*};
 use language_model::{
-    ConfigurationViewTargetAgent, IconOrSvg, LanguageModelProvider, LanguageModelProviderId,
-    LanguageModelRegistry, ProviderConfigurationView,
+    ApiKeyConfiguration, ConfigurationViewTargetAgent, IconOrSvg, InlineDescription,
+    LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry,
+    ProviderConfigurationView,
 };
-use ui::{Divider, DividerColor, prelude::*};
+use ui::{ButtonLink, ConfiguredApiCard, Divider, DividerColor, prelude::*};
 
 use crate::SettingsWindow;
+use crate::components::SettingsInputField;
 
 pub(crate) fn render_llm_providers_page(
     settings_window: &SettingsWindow,
@@ -20,7 +22,6 @@ pub(crate) fn render_llm_providers_page(
     v_flex()
         .id("llm-providers-page")
         .size_full()
-        .pt_2p5()
         .px_8()
         .pb_16()
         .track_scroll(scroll_handle)
@@ -28,94 +29,272 @@ pub(crate) fn render_llm_providers_page(
         .children(
             providers
                 .iter()
-                .map(|provider| render_provider_row(settings_window, provider, window, cx))
+                .enumerate()
+                .map(|(index, provider)| {
+                    render_provider_section(settings_window, provider, index == 0, window, cx)
+                })
                 .collect::>(),
         )
         .into_any_element()
 }
 
-fn render_provider_row(
+fn render_provider_section(
     settings_window: &SettingsWindow,
     provider: &Arc,
+    is_first: bool,
     window: &mut Window,
     cx: &mut Context,
 ) -> AnyElement {
     let provider_id = provider.id();
     let provider_name = provider.name().0;
-    let is_authenticated = provider.is_authenticated(cx);
 
-    let icon = match provider.icon() {
+    let body = if let Some(config) = provider.api_key_configuration(cx) {
+        render_api_key_providers_item(provider, provider_name.clone(), config)
+    } 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),
+        }
+    };
+
+    v_flex()
+        .min_w_0()
+        .map(|s| if is_first { s.pt_4() } else { s.pt_8() })
+        .gap_1p5()
+        .child(render_provider_header(provider_name, provider.icon(), cx))
+        .child(body)
+        .into_any_element()
+}
+
+/// An icon + name header with a faded divider, mirroring `SettingsSectionHeader`
+/// but able to render providers' external SVG icons.
+fn render_provider_header(
+    provider_name: SharedString,
+    icon: IconOrSvg,
+    cx: &mut Context,
+) -> impl IntoElement {
+    let icon = match icon {
         IconOrSvg::Svg(path) => Icon::from_external_svg(path),
         IconOrSvg::Icon(name) => Icon::new(name),
     }
-    .size(IconSize::Small)
     .color(Color::Muted);
 
-    let left = h_flex()
-        .flex_none()
+    v_flex()
+        .w_full()
         .gap_1p5()
-        .child(icon)
-        .child(Label::new(provider_name))
-        .when(is_authenticated, |this| {
-            this.child(
-                Icon::new(IconName::Check)
-                    .size(IconSize::Small)
-                    .color(Color::Success),
-            )
-        });
+        .child(
+            h_flex().gap_1p5().child(icon).child(
+                Label::new(provider_name)
+                    .size(LabelSize::Small)
+                    .color(Color::Muted)
+                    .buffer_font(cx),
+            ),
+        )
+        .child(Divider::horizontal().color(DividerColor::BorderFaded))
+}
 
-    // The provider tells us how it wants to be presented: a compact inline
-    // control, or a richer view that belongs on its own sub-page.
-    let control =
-        match get_or_create_configuration_view(settings_window, &provider_id, provider, window, cx)
-        {
-            ProviderConfigurationView::Inline(view) => v_flex()
-                .min_w_0()
-                .w_full()
-                .max_w(rems(24.))
-                .child(view)
-                .into_any_element(),
-            ProviderConfigurationView::SubPage(_) => render_configure_button(&provider_id, cx),
+fn render_api_key_providers_item(
+    provider: &Arc,
+    provider_name: SharedString,
+    config: ApiKeyConfiguration,
+) -> AnyElement {
+    let ApiKeyConfiguration {
+        has_key,
+        is_from_env_var,
+        env_var_name,
+        api_key_url,
+    } = config;
+
+    if has_key {
+        let configured_label = if is_from_env_var {
+            "API Key Set in Environment Variable"
+        } else {
+            "API Key Configured"
         };
+        let button_id = format!("reset-api-key-{}", provider.id().0);
+        let provider = provider.clone();
+        let env_var_name_for_tooltip = env_var_name;
 
-    v_flex()
-        .min_w_0()
+        return ConfiguredApiCard::new(button_id, configured_label)
+            .button_label("Reset Key")
+            .button_tab_index(0)
+            .disabled(is_from_env_var)
+            .when(is_from_env_var, |this| {
+                this.tooltip_label(format!(
+                    "To reset your API key, unset the {env_var_name_for_tooltip} environment variable."
+                ))
+            })
+            .on_click(move |_, _, cx| {
+                provider.reset_credentials(cx).detach_and_log_err(cx);
+            })
+            .into_any_element();
+    }
+
+    let input_id = format!("{}-api-key-input", provider.id().0);
+    let aria_label = format!("{provider_name} API Key");
+    let provider = provider.clone();
+
+    h_flex()
+        .pt_2p5()
         .w_full()
+        .min_w_0()
+        .gap_4()
+        .justify_between()
+        .child(
+            v_flex()
+                .w_full()
+                .min_w_0()
+                .max_w_1_2()
+                .gap_0p5()
+                .child(Label::new("API Key"))
+                .child(
+                    h_flex()
+                        .w_full()
+                        .min_w_0()
+                        .flex_wrap()
+                        .gap_0p5()
+                        .child(
+                            Label::new("Visit the")
+                                .size(LabelSize::Small)
+                                .color(Color::Muted),
+                        )
+                        .child(
+                            ButtonLink::new(format!("{provider_name} dashboard"), api_key_url)
+                                .no_icon(true)
+                                .label_size(LabelSize::Small)
+                                .label_color(Color::Muted),
+                        )
+                        .child(
+                            Label::new("to generate an API key.")
+                                .size(LabelSize::Small)
+                                .color(Color::Muted),
+                        ),
+                )
+                .child(
+                    Label::new(format!(
+                        "Or set the {env_var_name} env var and restart Zed for it to take effect."
+                    ))
+                    .size(LabelSize::XSmall)
+                    .color(Color::Muted),
+                ),
+        )
         .child(
-            div()
-                .px_2()
-                .child(Divider::horizontal().color(DividerColor::BorderFaded)),
+            SettingsInputField::new(input_id)
+                .tab_index(0)
+                .with_placeholder("xxxxxxxxxxxxxxxxxxxx")
+                .aria_label(aria_label)
+                .on_confirm(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);
+                    }
+                }),
         )
+        .into_any_element()
+}
+
+fn render_inline_body(
+    provider: &Arc,
+    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()
+            .w_full()
+            .min_w_0()
+            .child(view)
+            .into_any_element();
+    }
+
+    h_flex()
+        .pt_2p5()
+        .w_full()
+        .min_w_0()
+        .gap_4()
+        .justify_between()
         .child(
-            h_flex()
+            v_flex()
                 .w_full()
-                .py_2()
-                .px_2()
-                .gap_6()
-                .justify_between()
-                .items_start()
-                .child(left)
-                .child(control),
+                .min_w_0()
+                .max_w_1_2()
+                .when_some(title, |this, title| this.child(Label::new(title)))
+                .when_some(description, |this, description| {
+                    this.child(render_inline_description(provider_name, description))
+                }),
         )
+        .child(h_flex().flex_none().child(view))
         .into_any_element()
 }
 
-fn render_configure_button(
-    provider_id: &LanguageModelProviderId,
+fn render_subpage_item(
+    provider: &Arc,
     cx: &mut Context,
 ) -> AnyElement {
-    let provider_id = provider_id.clone();
-    Button::new(
-        SharedString::from(format!("configure-{}", provider_id.0)),
-        "Configure",
-    )
-    .style(ButtonStyle::Outlined)
-    .label_size(LabelSize::Small)
-    .tab_index(0isize)
-    .on_click(cx.listener(move |this, _, window, cx| {
-        open_provider_configuration(this, provider_id.clone(), window, cx);
-    }))
-    .into_any_element()
+    let provider_id = provider.id();
+    let provider_name = provider.name().0;
+    let description = provider.inline_description(cx);
+
+    h_flex()
+        .pt_2p5()
+        .w_full()
+        .min_w_0()
+        .gap_4()
+        .justify_between()
+        .child(
+            v_flex()
+                .w_full()
+                .min_w_0()
+                .max_w_1_2()
+                .gap_0p5()
+                .child(Label::new("Configure Provider"))
+                .when_some(description, |this, description| {
+                    this.child(render_inline_description(provider_name, description))
+                }),
+        )
+        .child(
+            Button::new(format!("configure-{}", provider_id.0), "Configure")
+                .style(ButtonStyle::OutlinedGhost)
+                .size(ButtonSize::Medium)
+                .end_icon(
+                    Icon::new(IconName::ChevronRight)
+                        .size(IconSize::Small)
+                        .color(Color::Muted),
+                )
+                .tab_index(0isize)
+                .on_click(cx.listener(move |this, _, window, cx| {
+                    open_provider_configuration(this, provider_id.clone(), window, cx);
+                })),
+        )
+        .into_any_element()
+}
+
+fn render_inline_description(
+    provider_name: SharedString,
+    description: InlineDescription,
+) -> AnyElement {
+    match description {
+        InlineDescription::ApiKeyUrl(url) => h_flex()
+            .gap_0p5()
+            .child(
+                Label::new("To find an API key, visit the")
+                    .size(LabelSize::Small)
+                    .color(Color::Muted),
+            )
+            .child(
+                ButtonLink::new(format!("{provider_name} dashboard."), url)
+                    .label_size(LabelSize::Small),
+            )
+            .into_any_element(),
+        InlineDescription::Text(text) => Label::new(text)
+            .size(LabelSize::Small)
+            .color(Color::Muted)
+            .into_any_element(),
+    }
 }
 
 fn open_provider_configuration(
@@ -164,7 +343,8 @@ fn render_provider_config_sub_page(
         window,
         cx,
     ) {
-        ProviderConfigurationView::Inline(view) | ProviderConfigurationView::SubPage(view) => view,
+        ProviderConfigurationView::Inline { view, .. }
+        | ProviderConfigurationView::SubPage(view) => view,
     };
 
     v_flex()
diff --git a/crates/settings_ui/src/pages/mcp_servers_page.rs b/crates/settings_ui/src/pages/mcp_servers_page.rs
index bebf1e795ce0ac..f06e6b33beebeb 100644
--- a/crates/settings_ui/src/pages/mcp_servers_page.rs
+++ b/crates/settings_ui/src/pages/mcp_servers_page.rs
@@ -11,8 +11,8 @@ use project::context_server_store::{
 use project::project_settings::ContextServerSettings;
 use settings::{ContextServerCommand, ContextServerSettingsContent, OAuthClientSettings};
 use ui::{
-    AiSettingItem, AiSettingItemSource, AiSettingItemStatus, ContextMenu, Divider, DividerColor,
-    PopoverMenu, Switch, ToggleState, Tooltip, prelude::*,
+    AiSettingItem, AiSettingItemSource, AiSettingItemStatus, ContextMenu, Divider, PopoverMenu,
+    Switch, ToggleState, Tooltip, prelude::*,
 };
 use util::ResultExt as _;
 
@@ -41,34 +41,29 @@ pub(crate) fn render_mcp_servers_page(
     };
 
     let timeout_setting = render_context_server_timeout(settings_window, window, cx);
-    let add_server_popover = render_add_server_popover(settings_window, window, cx);
 
     v_flex()
         .id("mcp-servers-page")
+        .track_scroll(scroll_handle)
         .size_full()
         .pt_2p5()
         .pb_16()
-        .track_scroll(scroll_handle)
         .overflow_y_scroll()
         .child(
-            h_flex()
+            v_flex()
                 .w_full()
                 .px_8()
-                .justify_between()
-                .items_center()
-                .mb_4()
+                .gap_2()
                 .child(
-                    v_flex()
-                        .child(Label::new("MCP Servers").size(LabelSize::Large))
-                        .child(
-                            Label::new("Manage Model Context Protocol servers connected directly or via extensions.")
-                                .size(LabelSize::Small)
-                                .color(Color::Muted),
-                        ),
+                    v_flex().child(Label::new("Configured Servers")).child(
+                        Label::new("Manage servers connected directly or via extensions.")
+                            .size(LabelSize::Small)
+                            .color(Color::Muted),
+                    ),
                 )
-                .child(add_server_popover),
+                .child(server_list)
+                .child(Divider::horizontal()),
         )
-        .child(div().px_8().child(server_list))
         .child(timeout_setting)
         .into_any_element()
 }
@@ -151,11 +146,7 @@ fn render_server_list(
             server_ids
                 .iter()
                 .map(|server_id| render_context_server(server_id, store, cx).into_any_element()),
-            || {
-                Divider::horizontal()
-                    .color(DividerColor::BorderFaded)
-                    .into_any_element()
-            },
+            || Divider::horizontal_dashed().into_any_element(),
         ))
         .into_any_element()
 }
@@ -213,22 +204,18 @@ fn render_context_server(
         None
     };
 
-    // Build gear menu. Pre-fill "Configure Server" from the raw configured
-    // settings (not the resolved runtime configuration) so the form is editable
-    // even when the settings contain invalid data (e.g. an unparsable URL) or
-    // the server is disabled / not yet started.
     let server_settings = store
         .read(cx)
         .settings_for_server(context_server_id)
         .cloned();
-    let gear_menu = render_gear_menu(
-        context_server_id,
-        store,
-        cx.entity().downgrade(),
-        server_settings.clone(),
-        provided_by_extension,
-        should_show_logout,
-    );
+    let configure_button = (!provided_by_extension).then(|| {
+        render_configure_button(
+            context_server_id,
+            cx.entity().downgrade(),
+            server_settings.clone(),
+        )
+    });
+    let uninstall_button = render_uninstall_button(context_server_id, provided_by_extension);
 
     // Build toggle switch
     let toggle_switch = render_toggle_switch(context_server_id, store, is_running);
@@ -241,7 +228,8 @@ fn render_context_server(
     };
 
     AiSettingItem::new(item_id, display_name, status, source)
-        .action(gear_menu)
+        .when_some(configure_button, |this, button| this.action(button))
+        .action(uninstall_button)
         .action(toggle_switch)
         .when_some(tool_label, |this, label| this.detail_label(label))
         .when_some(details, |this, details| this.details(details))
@@ -278,85 +266,51 @@ fn resolve_extension_display_name(id: &ContextServerId, cx: &App) -> Option,
     settings_window: WeakEntity,
     server_settings: Option,
-    provided_by_extension: bool,
-    should_show_logout: bool,
 ) -> impl IntoElement {
     let context_server_id = context_server_id.clone();
-    let store = store.clone();
 
-    PopoverMenu::new(SharedString::from(format!(
-        "mcp-gear-{}",
-        context_server_id.0
-    )))
-    .trigger_with_tooltip(
-        IconButton::new(
-            SharedString::from(format!("mcp-gear-btn-{}", context_server_id.0)),
-            IconName::Settings,
-        )
-        .icon_color(Color::Muted)
-        .icon_size(IconSize::Small)
-        .tab_index(0isize),
-        Tooltip::text("Configure MCP Server"),
+    IconButton::new(
+        format!("mcp-configure-btn-{}", context_server_id.0),
+        IconName::Settings,
     )
-    .anchor(gpui::Anchor::TopRight)
-    .menu({
-        move |window, cx| {
-            let context_server_id = context_server_id.clone();
-            let store = store.clone();
-            let settings_window = settings_window.clone();
-            let server_settings = server_settings.clone();
+    .icon_size(IconSize::Small)
+    .tab_index(0isize)
+    .tooltip(Tooltip::text("Configure MCP Server"))
+    .on_click(move |_event, window, cx| {
+        let transport = match &server_settings {
+            Some(ContextServerSettings::Http { .. }) => McpTransport::Http,
+            _ => McpTransport::Stdio,
+        };
+        let existing = server_settings
+            .clone()
+            .map(|settings| (context_server_id.clone(), settings));
+        settings_window
+            .update(cx, |this, cx| {
+                open_mcp_server_form(this, transport, existing, window, cx);
+            })
+            .log_err();
+    })
+}
 
-            Some(ContextMenu::build(window, cx, move |menu, _window, _cx| {
-                menu.when(!provided_by_extension, |this| {
-                    this.entry("Configure Server", None, {
-                        let settings_window = settings_window.clone();
-                        let context_server_id = context_server_id.clone();
-                        let server_settings = server_settings.clone();
-                        move |window, cx| {
-                            let transport = match &server_settings {
-                                Some(ContextServerSettings::Http { .. }) => McpTransport::Http,
-                                _ => McpTransport::Stdio,
-                            };
-                            let existing = server_settings
-                                .clone()
-                                .map(|settings| (context_server_id.clone(), settings));
-                            settings_window
-                                .update(cx, |this, cx| {
-                                    open_mcp_server_form(this, transport, existing, window, cx);
-                                })
-                                .log_err();
-                        }
-                    })
-                })
-                .when(should_show_logout, |this| {
-                    this.entry("Log Out", None, {
-                        let store = store.clone();
-                        let context_server_id = context_server_id.clone();
-                        move |_window, cx| {
-                            store.update(cx, |s, cx| {
-                                s.logout_server(&context_server_id, cx).log_err();
-                            });
-                        }
-                    })
-                })
-                // Only show a divider when there is an entry above "Uninstall".
-                // Extension servers have neither "Configure Server" nor "Log Out".
-                .when(!provided_by_extension || should_show_logout, |this| {
-                    this.separator()
-                })
-                .entry("Uninstall", None, {
-                    let context_server_id = context_server_id.clone();
-                    move |_, cx| {
-                        uninstall_server(&context_server_id, provided_by_extension, cx);
-                    }
-                })
-            }))
-        }
+fn render_uninstall_button(
+    context_server_id: &ContextServerId,
+    provided_by_extension: bool,
+) -> impl IntoElement {
+    let context_server_id = context_server_id.clone();
+
+    IconButton::new(
+        format!("mcp-uninstall-btn-{}", context_server_id.0),
+        IconName::Trash,
+    )
+    .icon_size(IconSize::Small)
+    .tab_index(0isize)
+    .tooltip(Tooltip::text("Uninstall MCP Server"))
+    .on_click(move |_event, _window, cx| {
+        uninstall_server(&context_server_id, provided_by_extension, cx);
     })
 }
 
@@ -432,7 +386,6 @@ fn render_status_details(
                 feedback_base()
                     .child(
                         h_flex()
-                            .pr_4()
                             .min_w_0()
                             .w_full()
                             .gap_2()
@@ -475,7 +428,6 @@ fn render_status_details(
                 feedback_base()
                     .child(
                         h_flex()
-                            .pr_4()
                             .min_w_0()
                             .w_full()
                             .gap_2()
@@ -509,7 +461,6 @@ fn render_status_details(
             feedback_base()
                 .child(
                     h_flex()
-                        .pr_4()
                         .min_w_0()
                         .w_full()
                         .gap_2()
@@ -529,7 +480,6 @@ fn render_status_details(
         ContextServerStatus::Authenticating => Some(
             h_flex()
                 .mt_1()
-                .pr_4()
                 .min_w_0()
                 .w_full()
                 .gap_2()
@@ -541,11 +491,32 @@ fn render_status_details(
                 )
                 .into_any_element(),
         ),
+        ContextServerStatus::Running if should_show_logout => {
+            let store = store.clone();
+            let context_server_id = context_server_id.clone();
+            Some(
+                h_flex()
+                    .py_1()
+                    .w_full()
+                    .justify_end()
+                    .child(
+                        Button::new("running-logout", "Log Out")
+                            .style(ButtonStyle::Outlined)
+                            .label_size(LabelSize::Small)
+                            .on_click(move |_event, _window, cx| {
+                                store.update(cx, |s, cx| {
+                                    s.logout_server(&context_server_id, cx).log_err();
+                                });
+                            }),
+                    )
+                    .into_any_element(),
+            )
+        }
         _ => None,
     }
 }
 
-fn render_add_server_popover(
+pub(crate) fn render_add_server_popover(
     settings_window: &SettingsWindow,
     window: &mut Window,
     cx: &mut Context,
diff --git a/crates/settings_ui/src/pages/sandbox_settings.rs b/crates/settings_ui/src/pages/sandbox_settings.rs
index d5ee3c549032bb..9ae15eb738e95a 100644
--- a/crates/settings_ui/src/pages/sandbox_settings.rs
+++ b/crates/settings_ui/src/pages/sandbox_settings.rs
@@ -10,8 +10,6 @@ use util::ResultExt as _;
 use crate::SettingsWindow;
 use crate::components::{SettingsInputField, SettingsSectionHeader};
 
-const SANDBOX_DISCLAIMER: &str = "Customize how the sandbox for the agents tool should behave.";
-
 const DOMAINS_DESCRIPTION: &str = "Each entry is an exact domain (github.com) or a leading-*. subdomain wildcard (*.npmjs.org). IP addresses and local domains are not allowed.";
 
 const WRITE_PATHS_DESCRIPTION: &str =
@@ -58,17 +56,9 @@ pub(crate) fn render_sandbox_settings_page(
         .pt_2p5()
         .px_8()
         .pb_16()
-        .gap_4()
+        .gap_6()
         .overflow_y_scroll()
         .track_scroll(scroll_handle)
-        .child(
-            Banner::new().child(
-                Label::new(SANDBOX_DISCLAIMER)
-                    .size(LabelSize::Small)
-                    .color(Color::Muted)
-                    .mt_0p5(),
-            ),
-        )
         .child(
             SwitchField::new(
                 "sandbox-enabled",
@@ -102,7 +92,7 @@ pub(crate) fn render_sandbox_settings_page(
         })
         .child(
             v_flex()
-                .gap_3()
+                .gap_4()
                 .child(SettingsSectionHeader::new("Network").no_padding(true))
                 .child(
                     SwitchField::new(
@@ -130,7 +120,7 @@ pub(crate) fn render_sandbox_settings_page(
         .child(Divider::horizontal())
         .child(
             v_flex()
-                .gap_3()
+                .gap_4()
                 .child(SettingsSectionHeader::new("Git").no_padding(true))
                 .child(
                     SwitchField::new(
@@ -151,14 +141,14 @@ pub(crate) fn render_sandbox_settings_page(
         .child(Divider::horizontal())
         .child(
             v_flex()
-                .gap_3()
-                .child(SettingsSectionHeader::new("Filesystem").no_padding(true))
+                .gap_4()
+                .child(SettingsSectionHeader::new("File System").no_padding(true))
                 .child(
                     SwitchField::new(
                         "sandbox-allow-fs-write-all",
-                        Some("Allow All Filesystem Writes"),
+                        Some("Allow All File System Writes"),
                         Some(
-                            "Let sandboxed commands write anywhere on the filesystem without prompting."
+                            "Let sandboxed commands write anywhere on the file system without prompting."
                                 .into(),
                         ),
                         permissions.allow_fs_write_all,
diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs
index 991789eb5a7711..b371fb68ccbe30 100644
--- a/crates/settings_ui/src/settings_ui.rs
+++ b/crates/settings_ui/src/settings_ui.rs
@@ -51,7 +51,7 @@ use workspace::{
 };
 use zed_actions::{
     AGENT_SKILLS_SETTINGS_PATH, OpenProjectSettings, OpenSettings, OpenSettingsAt,
-    OpenSettingsAtTarget,
+    OpenSettingsAtTarget, OpenSettingsPage,
 };
 
 use crate::components::{
@@ -450,6 +450,15 @@ pub fn init(cx: &mut App) {
                     cx,
                 );
             })
+            .register_action(|_, action: &OpenSettingsPage, window, cx| {
+                let window_handle = window.window_handle().downcast::();
+                open_settings_editor_to_page(
+                    &action.page,
+                    action.target.as_ref().map(SettingsFileTarget::from),
+                    window_handle,
+                    cx,
+                );
+            })
             .register_action(|_, _: &OpenSettings, window, cx| {
                 let window_handle = window.window_handle().downcast::();
                 open_settings_editor(None, None, window_handle, cx);
@@ -669,30 +678,71 @@ pub fn open_settings_editor(
     );
 }
 
-fn open_settings_editor_at_target(
-    path: Option<&str>,
+fn select_settings_file_target(
+    target_file: SettingsFileTarget,
+    settings_window: &mut SettingsWindow,
+    window: &mut Window,
+    cx: &mut Context,
+) {
+    let file_index = settings_window
+        .files
+        .iter()
+        .position(|(file, _)| match target_file {
+            SettingsFileTarget::User => matches!(file, SettingsUiFile::User),
+            SettingsFileTarget::Project(worktree_id) => file.worktree_id() == Some(worktree_id),
+        });
+    if let Some(file_index) = file_index {
+        settings_window.change_file(file_index, window, cx);
+    }
+}
+
+fn open_settings_editor_to_page(
+    page: &str,
     target_file: Option,
     workspace_handle: Option>,
     cx: &mut App,
 ) {
-    fn select_target_file(
-        target_file: SettingsFileTarget,
-        settings_window: &mut SettingsWindow,
-        window: &mut Window,
-        cx: &mut Context,
-    ) {
-        let file_index = settings_window
-            .files
-            .iter()
-            .position(|(file, _)| match target_file {
-                SettingsFileTarget::User => matches!(file, SettingsUiFile::User),
-                SettingsFileTarget::Project(worktree_id) => file.worktree_id() == Some(worktree_id),
-            });
-        if let Some(file_index) = file_index {
-            settings_window.change_file(file_index, window, cx);
+    let page = page.to_string();
+    open_settings_editor_with(workspace_handle, cx, move |settings_window, window, cx| {
+        if let Some(target_file) = target_file {
+            select_settings_file_target(target_file, settings_window, window, cx);
         }
-    }
 
+        settings_window.opening_link = false;
+        settings_window.search_bar.update(cx, |editor, cx| {
+            editor.set_text(String::new(), window, cx);
+        });
+        for page_filter in &mut settings_window.filter_table {
+            page_filter.fill(true);
+        }
+        settings_window.has_query = false;
+        settings_window.filter_matches_to_file();
+
+        let Some(navbar_entry_index) = settings_window
+            .navbar_entries
+            .iter()
+            .position(|entry| entry.is_root && entry.title.eq_ignore_ascii_case(&page))
+        else {
+            log::error!("settings page not found: {page}");
+            return;
+        };
+
+        settings_window.open_and_scroll_to_navbar_entry(
+            navbar_entry_index,
+            None,
+            false,
+            window,
+            cx,
+        );
+    });
+}
+
+fn open_settings_editor_at_target(
+    path: Option<&str>,
+    target_file: Option,
+    workspace_handle: Option>,
+    cx: &mut App,
+) {
     /// Assumes a settings GUI window is already open
     fn open_path(
         path: &str,
@@ -740,7 +790,7 @@ fn open_settings_editor_at_target(
     let path = path.map(ToOwned::to_owned);
     open_settings_editor_with(workspace_handle, cx, move |settings_window, window, cx| {
         if let Some(target_file) = target_file {
-            select_target_file(target_file, settings_window, window, cx);
+            select_settings_file_target(target_file, settings_window, window, cx);
         }
         if let Some(path) = path {
             open_path(&path, settings_window, window, cx);
@@ -1772,6 +1822,12 @@ impl SettingsWindow {
         })
         .detach();
 
+        let language_model_registry = language_model::LanguageModelRegistry::global(cx);
+        cx.subscribe(&language_model_registry, |_, _, _event, cx| {
+            cx.notify();
+        })
+        .detach();
+
         cx.on_window_closed(|cx, _window_id| {
             if let Some(existing_window) = cx
                 .windows()
@@ -3651,6 +3707,9 @@ impl SettingsWindow {
         if let Some(current_sub_page) = self.sub_page_stack.last() {
             let is_skills_page =
                 current_sub_page.link.json_path == Some(AGENT_SKILLS_SETTINGS_PATH);
+            let is_external_agents_page = current_sub_page.link.json_path == Some("agent_servers");
+            let is_mcp_servers_page = current_sub_page.link.json_path == Some("context_servers");
+
             page_header = h_flex()
                 .w_full()
                 .min_w_0()
@@ -3701,6 +3760,12 @@ impl SettingsWindow {
                                         );
                                     })),
                             )
+                        })
+                        .when(is_external_agents_page, |this| {
+                            this.child(pages::render_add_agent_popover(self, window, cx))
+                        })
+                        .when(is_mcp_servers_page, |this| {
+                            this.child(pages::render_add_server_popover(self, window, cx))
                         }),
                 )
                 .into_any_element();
@@ -5227,6 +5292,7 @@ pub mod test {
         theme_settings::init(theme::LoadThemes::JustBase, cx);
         editor::init(cx);
         menu::init();
+        language_model::init(cx);
     }
 
     fn parse(input: &'static str, window: &mut Window, cx: &mut App) -> SettingsWindow {
diff --git a/crates/ui/src/components/ai/ai_setting_item.rs b/crates/ui/src/components/ai/ai_setting_item.rs
index 372ff75597dff7..f2b9abdd015ed4 100644
--- a/crates/ui/src/components/ai/ai_setting_item.rs
+++ b/crates/ui/src/components/ai/ai_setting_item.rs
@@ -21,9 +21,9 @@ impl AiSettingItemStatus {
             Self::Starting => "Server is starting.",
             Self::Running => "Server is active.",
             Self::Error => "Server has an error.",
-            Self::AuthRequired => "Authentication required.",
-            Self::ClientSecretRequired => "Client secret required.",
-            Self::Authenticating => "Waiting for authorization…",
+            Self::AuthRequired => "Authentication Required.",
+            Self::ClientSecretRequired => "Client Secret Required.",
+            Self::Authenticating => "Waiting for Authorization…",
         }
     }
 
@@ -198,6 +198,7 @@ impl RenderOnce for AiSettingItem {
         v_flex()
             .id(id)
             .min_w_0()
+            .py_2()
             .child(
                 h_flex()
                     .min_w_0()
diff --git a/crates/ui/src/components/ai/configured_api_card.rs b/crates/ui/src/components/ai/configured_api_card.rs
index 9e89acc4962ef0..d2ccc18d0e7f91 100644
--- a/crates/ui/src/components/ai/configured_api_card.rs
+++ b/crates/ui/src/components/ai/configured_api_card.rs
@@ -1,8 +1,9 @@
 use crate::{Tooltip, prelude::*};
-use gpui::{ClickEvent, IntoElement, ParentElement, SharedString};
+use gpui::{ClickEvent, ElementId, IntoElement, ParentElement, SharedString};
 
 #[derive(IntoElement, RegisterComponent)]
 pub struct ConfiguredApiCard {
+    id: ElementId,
     label: SharedString,
     button_label: Option,
     button_tab_index: Option,
@@ -12,8 +13,9 @@ pub struct ConfiguredApiCard {
 }
 
 impl ConfiguredApiCard {
-    pub fn new(label: impl Into) -> Self {
+    pub fn new(id: impl Into, label: impl Into) -> Self {
         Self {
+            id: id.into(),
             label: label.into(),
             button_label: None,
             button_tab_index: None,
@@ -52,6 +54,51 @@ impl ConfiguredApiCard {
     }
 }
 
+impl RenderOnce for ConfiguredApiCard {
+    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+        let button_label = self.button_label.unwrap_or("Reset Key".into());
+        let button_id = self.id;
+
+        h_flex()
+            .min_w_0()
+            .mt_0p5()
+            .p_1()
+            .flex_wrap()
+            .justify_between()
+            .rounded_md()
+            .border_1()
+            .border_color(cx.theme().colors().border_variant)
+            .bg(cx.theme().colors().background.opacity(0.5))
+            .child(
+                h_flex()
+                    .min_w_0()
+                    .gap_1()
+                    .child(Icon::new(IconName::Check).color(Color::Success))
+                    .child(Label::new(self.label)),
+            )
+            .child(
+                Button::new(button_id, button_label)
+                    .when_some(self.button_tab_index, |elem, tab_index| {
+                        elem.tab_index(tab_index)
+                    })
+                    .label_size(LabelSize::Small)
+                    .start_icon(
+                        Icon::new(IconName::Undo)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    )
+                    .disabled(self.disabled)
+                    .when_some(self.tooltip_label, |this, label| {
+                        this.tooltip(Tooltip::text(label))
+                    })
+                    .when_some(
+                        self.on_click.filter(|_| !self.disabled),
+                        |this, on_click| this.on_click(on_click),
+                    ),
+            )
+    }
+}
+
 impl Component for ConfiguredApiCard {
     fn scope() -> ComponentScope {
         ComponentScope::Agent
@@ -77,14 +124,14 @@ impl Component for ConfiguredApiCard {
             single_example(
                 "Default",
                 container()
-                    .child(ConfiguredApiCard::new("API key is configured"))
+                    .child(ConfiguredApiCard::new("default", "API key is configured"))
                     .into_any_element(),
             ),
             single_example(
                 "Custom Button Label",
                 container()
                     .child(
-                        ConfiguredApiCard::new("OpenAI API key configured")
+                        ConfiguredApiCard::new("custom-button-label", "OpenAI API key configured")
                             .button_label("Remove Key"),
                     )
                     .into_any_element(),
@@ -93,7 +140,7 @@ impl Component for ConfiguredApiCard {
                 "With Tooltip",
                 container()
                     .child(
-                        ConfiguredApiCard::new("Anthropic API key configured")
+                        ConfiguredApiCard::new("with-tooltip", "Anthropic API key configured")
                             .tooltip_label("Click to reset your API key"),
                     )
                     .into_any_element(),
@@ -101,7 +148,9 @@ impl Component for ConfiguredApiCard {
             single_example(
                 "Disabled",
                 container()
-                    .child(ConfiguredApiCard::new("API key is configured").disabled(true))
+                    .child(
+                        ConfiguredApiCard::new("disabled", "API key is configured").disabled(true),
+                    )
                     .into_any_element(),
             ),
         ];
@@ -109,48 +158,3 @@ impl Component for ConfiguredApiCard {
         example_group(examples).into_any_element()
     }
 }
-
-impl RenderOnce for ConfiguredApiCard {
-    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
-        let button_label = self.button_label.unwrap_or("Reset Key".into());
-        let button_id = SharedString::new(format!("id-{}", button_label));
-
-        h_flex()
-            .min_w_0()
-            .mt_0p5()
-            .p_1()
-            .justify_between()
-            .rounded_md()
-            .flex_wrap()
-            .border_1()
-            .border_color(cx.theme().colors().border)
-            .bg(cx.theme().colors().background)
-            .child(
-                h_flex()
-                    .min_w_0()
-                    .gap_1()
-                    .child(Icon::new(IconName::Check).color(Color::Success))
-                    .child(Label::new(self.label)),
-            )
-            .child(
-                Button::new(button_id, button_label)
-                    .when_some(self.button_tab_index, |elem, tab_index| {
-                        elem.tab_index(tab_index)
-                    })
-                    .label_size(LabelSize::Small)
-                    .start_icon(
-                        Icon::new(IconName::Undo)
-                            .size(IconSize::Small)
-                            .color(Color::Muted),
-                    )
-                    .disabled(self.disabled)
-                    .when_some(self.tooltip_label, |this, label| {
-                        this.tooltip(Tooltip::text(label))
-                    })
-                    .when_some(
-                        self.on_click.filter(|_| !self.disabled),
-                        |this, on_click| this.on_click(on_click),
-                    ),
-            )
-    }
-}
diff --git a/crates/ui_input/src/input_field.rs b/crates/ui_input/src/input_field.rs
index e1c5138475816d..eadadbfa8a5c87 100644
--- a/crates/ui_input/src/input_field.rs
+++ b/crates/ui_input/src/input_field.rs
@@ -177,11 +177,7 @@ impl Render for InputField {
             .w_full()
             .gap_1()
             .when_some(self.label.clone(), |this, label| {
-                this.child(
-                    Label::new(label)
-                        .size(self.label_size)
-                        .color(Color::Default),
-                )
+                this.child(Label::new(label).size(self.label_size))
             })
             .child(
                 h_flex()
diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs
index 0fe1b3fba01002..9d47f2724064f7 100644
--- a/crates/zed_actions/src/lib.rs
+++ b/crates/zed_actions/src/lib.rs
@@ -149,6 +149,18 @@ pub struct OpenSettingsAt {
     pub target: Option,
 }
 
+#[derive(PartialEq, Clone, Debug, Deserialize, JsonSchema, Action)]
+#[action(namespace = zed)]
+#[serde(deny_unknown_fields)]
+pub struct OpenSettingsPage {
+    /// A settings page title (e.g. `AI`).
+    pub page: String,
+    /// The settings file to select before opening `page`. When omitted, the
+    /// existing settings file selection is preserved.
+    #[serde(default)]
+    pub target: Option,
+}
+
 /// `OpenSettingsAt` path of the agent skills page in the settings UI.
 pub const AGENT_SKILLS_SETTINGS_PATH: &str = "agent.skills";
 
diff --git a/docs/src/ai/agent-settings.md b/docs/src/ai/agent-settings.md
index 3919ebc24d007e..6c29cc6dd102ab 100644
--- a/docs/src/ai/agent-settings.md
+++ b/docs/src/ai/agent-settings.md
@@ -1,19 +1,20 @@
 ---
 title: Agent Settings - Zed
-description: Map the Agent Settings panel to Zed AI setup pages for LLM providers, External Agents, MCP servers, and related settings.
+description: Map the AI settings pages to Zed AI setup for LLM providers, External Agents, MCP servers, and related settings.
 ---
 
 # Agent Settings
 
-Agent Settings is the in-panel configuration view for model providers, External Agents, and MCP servers. Open it with {#action agent::OpenSettings} or from the top-right menu in the [Agent Panel](./agent-panel.md).
+Agent Settings live in the **AI** section of the Settings Editor, which configures model providers, External Agents, and MCP servers.
+Open it with {#action agent::OpenSettings} (also available from the top-right menu in the [Agent Panel](./agent-panel.md)), which takes you straight to the AI page.
+You can also reach the same page with {#action zed::OpenSettings} and selecting **AI** in the sidebar.
 
-Agent Settings is different from the Settings Editor.
+Within the AI page, LLM Providers, External Agents, and MCP Servers each open as their own sub-page under the **General** section.
 
-| Surface              | Opens with                      | Use it for                                                                           |
-| -------------------- | ------------------------------- | ------------------------------------------------------------------------------------ |
-| Agent Settings panel | {#action agent::OpenSettings}   | LLM providers, External Agents, MCP servers                                          |
-| Settings Editor      | {#action zed::OpenSettings}     | General Zed settings, `disable_ai`, tool permissions, edit prediction provider setup |
-| Settings file        | {#action zed::OpenSettingsFile} | Direct JSON edits and settings not exposed in UI                                     |
+| Surface         | Opens with                      | Use it for                                                                  |
+| --------------- | ------------------------------- | --------------------------------------------------------------------------- |
+| Settings Editor | {#action agent::OpenSettings}   | AI settings: LLM providers, External Agents, MCP servers, and related pages |
+| Settings file   | {#action zed::OpenSettingsFile} | Direct JSON edits and settings not exposed in UI                            |
 
 For general settings mechanics, see [Configuring Zed](../configuring-zed.md).
 
@@ -95,23 +96,25 @@ For setup details and support boundaries, see [External Agents](./external-agent
 
 ## MCP Servers {#mcp-servers}
 
-The `Model Context Protocol (MCP) Servers` section configures MCP servers connected to Zed.
+The `MCP Servers` page configures Model Context Protocol servers connected to Zed.
 
-Use `Add Server` to:
+Use `Add Server` (in the page header) to:
 
-- `Add Custom Server`
+- `Add Local Server`
+- `Add Remote Server`
 - `Install from Extensions`
 
-Each configured server can expose actions such as:
+Each configured server row exposes:
 
-- `Configure Server`
-- `View Tools`
+- a `Configure` (gear) button to edit the server
+- an `Uninstall` (trash) button to remove it
+- a toggle to enable or disable it
 
 For MCP setup, auth, server status, and agent-path boundaries, see [MCP](./mcp.md).
 
 ## Related Configuration {#related-configuration}
 
-Some AI settings are not configured in the Agent Settings panel:
+Some AI settings are not configured on the AI settings pages:
 
 | Task                                                         | Go to                                          |
 | ------------------------------------------------------------ | ---------------------------------------------- |
diff --git a/docs/src/ai/external-agents.md b/docs/src/ai/external-agents.md
index dd418c32217c65..b8ed6aa5ad1c2b 100644
--- a/docs/src/ai/external-agents.md
+++ b/docs/src/ai/external-agents.md
@@ -17,7 +17,7 @@ For Zed-hosted models and Zed-managed AI features, see [AI Privacy](./privacy-an
 
 The ACP Registry is the primary way to install common External Agents in Zed.
 
-Open the registry with {#action zed::AcpRegistry}, or open [Agent Settings](./agent-settings.md) with {#action agent::OpenSettings}, click `Add Agent`, and choose `Install from Registry`.
+Open the registry with {#action zed::AcpRegistry}, or open [Agent Settings](./agent-settings.md) with {#action agent::OpenSettings}, go to the **External Agents** page, click `Add Agent`, and choose `Install from Registry`.
 
 After installation, the agent appears in the new-thread menu in the Agent Panel and Threads Sidebar.
 
@@ -130,7 +130,7 @@ Zed LLM provider API keys saved in the local keychain are not automatically the
 
 Use custom agents when you are developing an ACP-compatible agent or need to run an agent that is not in the registry.
 
-Open [Agent Settings](./agent-settings.md), click `Add Agent`, and choose `Add Custom Agent`. Zed opens your settings file with an `agent_servers` entry.
+Open [Agent Settings](./agent-settings.md), go to the **External Agents** page, click `Add Agent`, and choose `Add Custom Agent`. Zed opens your settings file with an `agent_servers` entry.
 
 ```json [settings]
 {
diff --git a/docs/src/ai/mcp.md b/docs/src/ai/mcp.md
index 30317dfad968ff..83c456be0bbd5b 100644
--- a/docs/src/ai/mcp.md
+++ b/docs/src/ai/mcp.md
@@ -35,7 +35,7 @@ Many MCP servers are available as extensions. Find them via:
 
 1. [the Zed website](https://zed.dev/extensions?filter=context-servers)
 2. in the app, open the Command Palette and run the {#action zed::Extensions} action
-3. in the app, go to the Agent Panel's top-right menu and look for the "Install New Servers…" menu item under the "MCP Servers" section
+3. in the app, open **Settings → AI → MCP Servers**, click `Add Server`, and choose `Install from Extensions`
 
 Popular servers available as an extension include:
 
@@ -72,7 +72,7 @@ You can connect both local and remote MCP servers easily utilizing the MCP serve
 }
 ```
 
-Alternatively, you can also open the modal by accessing the Agent Panel's Settings view (also accessible via the {#action agent::OpenSettings} action) and clicking the "Add Custom Server" button there.
+Alternatively, you can open the modal from **Settings → AI → MCP Servers** (also accessible via the {#action agent::OpenSettings} action, then selecting `MCP Servers`) and clicking the `Add Server` button in the page header, then choosing `Add Local Server` or `Add Remote Server`.
 
 > Note: When a remote MCP server has no configured `"Authorization"` header, Zed will prompt you to authenticate yourself against the MCP server using the standard MCP OAuth flow.
 
@@ -87,7 +87,7 @@ For example, the GitHub MCP extension requires you to add a [Personal Access Tok
 
 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.
 
-To check if your MCP server is properly configured, go to the Agent Panel's settings view and watch the indicator dot next to its name.
+To check if your MCP server is properly configured, open **Settings → AI → MCP Servers** and watch the indicator dot next to its name.
 If it's running correctly, the indicator will be green and its tooltip will say "Server is active".
 If not, other colors and tooltip messages will indicate what is happening.
 
diff --git a/docs/src/ai/use-a-gateway.md b/docs/src/ai/use-a-gateway.md
index 400b1a25767207..e010cfe8159aa2 100644
--- a/docs/src/ai/use-a-gateway.md
+++ b/docs/src/ai/use-a-gateway.md
@@ -20,7 +20,7 @@ Use OpenRouter when you want to route Zed AI features through OpenRouter.
 
 1. Visit [OpenRouter](https://openrouter.ai) and create an account.
 2. Generate an API key from your [OpenRouter keys page](https://openrouter.ai/keys).
-3. Open Agent Settings with {#action agent::OpenSettings} and go to the OpenRouter section.
+3. Open **Settings → AI → LLM Providers** with {#action agent::OpenSettings} and find the OpenRouter row.
 4. Enter your OpenRouter API key.
 
 Zed also reads `OPENROUTER_API_KEY` from the local Zed process environment.
@@ -104,7 +104,7 @@ Supported fields include `order`, `allow_fallbacks`, `require_parameters`, `data
 Use Vercel AI Gateway when you want to route Zed AI features through Vercel.
 
 1. Create an API key from your Vercel AI Gateway keys page.
-2. Open Agent Settings with {#action agent::OpenSettings} and go to the Vercel AI Gateway section.
+2. Open **Settings → AI → LLM Providers** with {#action agent::OpenSettings} and find the Vercel AI Gateway row.
 3. Enter your Vercel AI Gateway API key.
 
 Zed also reads `VERCEL_AI_GATEWAY_API_KEY` from the local Zed process environment.
diff --git a/docs/src/ai/use-api-access.md b/docs/src/ai/use-api-access.md
index 52b2eaf9605616..0ddf85ba33b77c 100644
--- a/docs/src/ai/use-api-access.md
+++ b/docs/src/ai/use-api-access.md
@@ -41,7 +41,7 @@ agent paths and model access paths.
 
 ## API Keys and Environment Variables {#api-keys}
 
-Most API-access providers can be configured in Zed's Agent Settings panel with {#action agent::OpenSettings}. Keys saved through Zed are stored in the system keychain, not in `settings.json`.
+Most API-access providers can be configured on the **Settings → AI → LLM Providers** page with {#action agent::OpenSettings}. Keys saved through Zed are stored in the system keychain, not in `settings.json`.
 
 Zed also reads provider-specific environment variables. Non-empty environment variables take precedence over keychain values. If a key comes from an environment variable, unset the variable and restart Zed to stop using it.
 

From 03125da5444120ed4bc015366987859e19c663f0 Mon Sep 17 00:00:00 2001
From: Bennet Bo Fenner 
Date: Wed, 1 Jul 2026 09:41:49 +0200
Subject: [PATCH 008/422] agent: Sanitize MCP tool names for stricter provider
 naming rules (#60154)

Closes #59038

Release Notes:

- agent: Fixed an issue where MCP servers with specific tool names would
cause errors when using Anthropic/OpenAI models
---
 crates/agent/src/tests/mod.rs | 91 +++++++++++++++++++++++++++++++++++
 crates/agent/src/thread.rs    | 42 +++++++++++-----
 2 files changed, 120 insertions(+), 13 deletions(-)

diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs
index 11f542ee8e6cf0..55e492c3b3f60c 100644
--- a/crates/agent/src/tests/mod.rs
+++ b/crates/agent/src/tests/mod.rs
@@ -1633,6 +1633,97 @@ async fn test_mcp_tools(cx: &mut TestAppContext) {
     events.collect::>().await;
 }
 
+#[gpui::test]
+async fn test_mcp_tool_names_are_sanitized_for_providers(cx: &mut TestAppContext) {
+    let ThreadTest {
+        model,
+        thread,
+        context_server_store,
+        fs,
+        ..
+    } = setup(cx, TestModel::Fake).await;
+    let fake_model = model.as_fake();
+
+    fs.insert_file(
+        paths::settings_file(),
+        json!({
+            "agent": {
+                "tool_permissions": { "default": "allow" },
+                "profiles": {
+                    "test": {
+                        "name": "Test Profile",
+                        "enable_all_context_servers": true,
+                    },
+                }
+            }
+        })
+        .to_string()
+        .into_bytes(),
+    )
+    .await;
+    cx.run_until_parked();
+    thread.update(cx, |thread, cx| {
+        thread.set_profile(AgentProfileId("test".into()), cx)
+    });
+
+    let mut mcp_tool_calls = setup_context_server(
+        "Superluminal",
+        vec![context_server::types::Tool {
+            name: "snake_case.PascalCase".into(),
+            title: None,
+            description: None,
+            input_schema: json!({"type": "object", "properties": {}}),
+            output_schema: None,
+            annotations: None,
+        }],
+        &context_server_store,
+        cx,
+    );
+
+    let events = thread.update(cx, |thread, cx| {
+        thread
+            .send(ClientUserMessageId::new(), ["Use the MCP tool"], cx)
+            .unwrap()
+    });
+    cx.run_until_parked();
+
+    let completion = fake_model.pending_completions().pop().unwrap();
+    assert_eq!(
+        tool_names_for_completion(&completion),
+        vec!["snake_case_PascalCase"]
+    );
+    fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
+        LanguageModelToolUse {
+            id: "tool_1".into(),
+            name: "snake_case_PascalCase".into(),
+            raw_input: json!({}).to_string(),
+            input: json!({}),
+            is_input_complete: true,
+            thought_signature: None,
+        },
+    ));
+    fake_model.end_last_completion_stream();
+    cx.run_until_parked();
+
+    let (tool_call_params, tool_call_response) = mcp_tool_calls.next().await.unwrap();
+    assert_eq!(tool_call_params.name, "snake_case.PascalCase");
+    tool_call_response
+        .send(context_server::types::CallToolResponse {
+            content: vec![context_server::types::ToolResponseContent::Text {
+                text: "done".into(),
+            }],
+            is_error: None,
+            meta: None,
+            structured_content: None,
+        })
+        .unwrap();
+    cx.run_until_parked();
+
+    fake_model.send_last_completion_stream_text_chunk("Done!");
+    fake_model.end_last_completion_stream();
+    events.collect::>().await;
+}
+
 #[gpui::test]
 async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) {
     let ThreadTest {
diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs
index 980ed067a3864f..6bc2715dd7e652 100644
--- a/crates/agent/src/thread.rs
+++ b/crates/agent/src/thread.rs
@@ -72,6 +72,27 @@ const TOOL_CANCELED_MESSAGE: &str = "Tool canceled by user";
 pub const MAX_TOOL_NAME_LENGTH: usize = 64;
 pub const MAX_SUBAGENT_DEPTH: u8 = 1;
 
+pub(crate) fn provider_compatible_tool_name(tool_name: &str) -> String {
+    let mut sanitized = String::new();
+    for character in tool_name.chars() {
+        if sanitized.len() >= MAX_TOOL_NAME_LENGTH {
+            break;
+        }
+
+        if character.is_ascii_alphanumeric() || character == '_' || character == '-' {
+            sanitized.push(character);
+        } else {
+            sanitized.push('_');
+        }
+    }
+
+    if sanitized.is_empty() {
+        sanitized.push_str("tool");
+    }
+
+    sanitized
+}
+
 #[derive(Clone, Debug, Eq, PartialEq)]
 pub struct SandboxStatusKey {
     pub settings_sandbox: ThreadSandbox,
@@ -3986,16 +4007,6 @@ impl Thread {
         let Some(profile) = AgentSettings::get_global(cx).profiles.get(&self.profile_id) else {
             return BTreeMap::new();
         };
-        fn truncate(tool_name: &SharedString) -> SharedString {
-            if tool_name.len() > MAX_TOOL_NAME_LENGTH {
-                let mut truncated = tool_name.to_string();
-                truncated.truncate(MAX_TOOL_NAME_LENGTH);
-                truncated.into()
-            } else {
-                tool_name.clone()
-            }
-        }
-
         // Terminal variants are configured by users under the canonical
         // `terminal` name. Expose the one matching the current sandbox state
         // to the model under that name.
@@ -4030,7 +4041,10 @@ impl Thread {
                             Some((SharedString::from(TerminalTool::NAME), tool.clone()))
                         }
                         (TerminalTool::NAME | SandboxedTerminalTool::NAME, _) => None,
-                        _ => Some((truncate(tool_name), tool.clone())),
+                        _ => Some((
+                            provider_compatible_tool_name(tool_name.as_ref()).into(),
+                            tool.clone(),
+                        )),
                     }
                 } else {
                     None
@@ -4045,7 +4059,8 @@ impl Thread {
         for (server_id, server_tools) in self.context_server_registry.read(cx).servers() {
             for (tool_name, tool) in server_tools {
                 if profile.is_context_server_tool_enabled(&server_id.0, &tool_name) {
-                    let tool_name = truncate(tool_name);
+                    let tool_name: SharedString =
+                        provider_compatible_tool_name(tool_name.as_ref()).into();
                     if !seen_tools.insert(tool_name.clone()) {
                         duplicate_tool_names.insert(tool_name.clone());
                     }
@@ -4062,7 +4077,8 @@ impl Thread {
             if duplicate_tool_names.contains(&tool_name) {
                 let available = MAX_TOOL_NAME_LENGTH.saturating_sub(tool_name.len());
                 if available >= 2 {
-                    let mut disambiguated = server_id.0.to_snake_case();
+                    let mut disambiguated =
+                        provider_compatible_tool_name(&server_id.0.to_snake_case()).to_string();
                     disambiguated.truncate(available - 1);
                     disambiguated.push('_');
                     disambiguated.push_str(&tool_name);

From 7b128f9263396555041d3c416ba75cf7554fe1a4 Mon Sep 17 00:00:00 2001
From: Xin Zhao 
Date: Wed, 1 Jul 2026 16:22:00 +0800
Subject: [PATCH 009/422] workspace: Use remote host's path style when
 validating trust scope (#60139)

# Objective

Zed supports trusting all projects under a given directory. However,
when a local Windows client connects to a remote Linux server, the trust
scope input rejects valid directories with the error "Enter an absolute
folder path", even though the path is correct.
image

The root cause is in the `validate_trust_scope` function:

https://github.com/zed-industries/zed/blob/53e4d34a71f61f13572919c04335e9da838623e6/crates/workspace/src/security_modal.rs#L497-L519
which uses `Path::is_absolute()` to check whether the entered path is
absolute. On Windows, this method only considers paths with a drive
letter as absolute, so Unix-style paths like `/home/user` are never
recognized. When doing cross-platform remote development, valid trust
scopes are incorrectly rejected.
## Solution
The `util::paths` crate already provides a helper for this situation:

https://github.com/zed-industries/zed/blob/53e4d34a71f61f13572919c04335e9da838623e6/crates/util/src/paths.rs#L575-L586
The fix
- Adds a `path_style: PathStyle` parameter to `validate_trust_scope` and
uses `util::paths::is_absolute()` in place of `Path::is_absolute()`.
- The call site in `edited_trust_scope` retrieves the path style from
the worktree store, which already tracks whether a connection is local
or remote and the remote host's path convention.

The associated unit tests were updated for the new function signature.

## Testing

Built and tested locally; `cargo test -p workspace` passed.

## 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 the "Folder to trust" input rejecting valid folder paths when
connecting to a remote host that uses a different path style than the
local machine.
---
 crates/workspace/src/security_modal.rs | 42 +++++++++++++++++---------
 1 file changed, 28 insertions(+), 14 deletions(-)

diff --git a/crates/workspace/src/security_modal.rs b/crates/workspace/src/security_modal.rs
index 3c8a831fb488ff..3f8e4d95b8ea29 100644
--- a/crates/workspace/src/security_modal.rs
+++ b/crates/workspace/src/security_modal.rs
@@ -22,6 +22,8 @@ use ui::{
 };
 use ui_input::InputField;
 
+use util::paths::PathStyle;
+
 use crate::{DismissDecision, ModalView, ToggleWorktreeSecurity};
 
 pub struct SecurityModal {
@@ -387,7 +389,12 @@ impl SecurityModal {
             return Ok(None);
         };
         let typed = self.trust_path_input.read(cx).text(cx);
-        validate_trust_scope(&typed, &project, self.home_dir.as_deref()).map(Some)
+        let path_style = self
+            .worktree_store
+            .upgrade()
+            .map(|store| store.read(cx).path_style())
+            .unwrap_or_else(PathStyle::local);
+        validate_trust_scope(&typed, &project, self.home_dir.as_deref(), path_style).map(Some)
     }
 
     fn trust_and_dismiss(&mut self, cx: &mut Context) {
@@ -498,18 +505,20 @@ fn validate_trust_scope(
     typed: &str,
     project: &Path,
     home_dir: Option<&Path>,
+    path_style: PathStyle,
 ) -> Result {
     let trimmed = typed.trim();
     if trimmed.is_empty() {
         return Err("Enter a folder to trust".into());
     }
     let expanded = match (trimmed.strip_prefix('~'), home_dir) {
-        (Some(rest), Some(home_dir)) => {
-            home_dir.join(rest.strip_prefix(std::path::MAIN_SEPARATOR).unwrap_or(rest))
-        }
+        (Some(rest), Some(home_dir)) => home_dir.join(
+            rest.strip_prefix(path_style.primary_separator())
+                .unwrap_or(rest),
+        ),
         _ => PathBuf::from(trimmed),
     };
-    if !expanded.is_absolute() {
+    if !util::paths::is_absolute(&expanded.to_string_lossy(), path_style) {
         return Err("Enter an absolute folder path".into());
     }
     if !project.starts_with(&expanded) {
@@ -525,39 +534,44 @@ mod tests {
     #[test]
     fn accepts_ancestor_or_equal() {
         let project = Path::new("/Users/me/dev/delta/wt/t1");
+        let style = PathStyle::Posix;
         assert_eq!(
-            validate_trust_scope("/Users/me/dev/delta/wt", project, None).unwrap(),
+            validate_trust_scope("/Users/me/dev/delta/wt", project, None, style).unwrap(),
             PathBuf::from("/Users/me/dev/delta/wt"),
         );
         // Equal to the project itself is allowed.
         assert_eq!(
-            validate_trust_scope("/Users/me/dev/delta/wt/t1", project, None).unwrap(),
+            validate_trust_scope("/Users/me/dev/delta/wt/t1", project, None, style).unwrap(),
             PathBuf::from("/Users/me/dev/delta/wt/t1"),
         );
         // A distant ancestor is allowed.
-        assert!(validate_trust_scope("/Users/me/dev", project, None).is_ok());
+        assert!(validate_trust_scope("/Users/me/dev", project, None, style).is_ok());
     }
 
     #[test]
     fn rejects_non_ancestor_relative_or_empty() {
         let project = Path::new("/Users/me/dev/delta/wt/t1");
-        assert!(validate_trust_scope("/Users/other", project, None).is_err());
-        assert!(validate_trust_scope("relative/path", project, None).is_err());
-        assert!(validate_trust_scope("   ", project, None).is_err());
+        let style = PathStyle::Posix;
+        assert!(validate_trust_scope("/Users/other", project, None, style).is_err());
+        assert!(validate_trust_scope("relative/path", project, None, style).is_err());
+        assert!(validate_trust_scope("   ", project, None, style).is_err());
         // Deeper than the project is not an ancestor.
-        assert!(validate_trust_scope("/Users/me/dev/delta/wt/t1/sub", project, None).is_err());
+        assert!(
+            validate_trust_scope("/Users/me/dev/delta/wt/t1/sub", project, None, style).is_err()
+        );
     }
 
     #[test]
     fn expands_leading_tilde() {
         let home = Path::new("/Users/me");
         let project = Path::new("/Users/me/dev/wt/t1");
+        let style = PathStyle::Posix;
         assert_eq!(
-            validate_trust_scope("~/dev/wt", project, Some(home)).unwrap(),
+            validate_trust_scope("~/dev/wt", project, Some(home), style).unwrap(),
             PathBuf::from("/Users/me/dev/wt"),
         );
         assert_eq!(
-            validate_trust_scope("~", project, Some(home)).unwrap(),
+            validate_trust_scope("~", project, Some(home), style).unwrap(),
             PathBuf::from("/Users/me"),
         );
     }

From 914cc661031a17c4e5e28901a5716bb8e638c621 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: Wed, 1 Jul 2026 11:34:33 +0200
Subject: [PATCH 010/422] Make the existing bookmark action add an unnamed
 bookmark again (#60185)

Zed don't surface the fact that bookmarks can have labels/names now
anywhere except opening the bookmark name editor every time you place
one. This keeps the label/name mechanism but makes the default add
bookmark action add an unnamed one.

Next step is probably to enable holding shift (or some other modifier)
to place a named bookmark, or even a setting to switch between default
named vs unnamed.

Release Notes:

- N/A
---
 crates/editor/src/actions.rs      |  2 ++
 crates/editor/src/bookmarks.rs    | 58 ++++++++++++++++---------------
 crates/editor/src/editor.rs       | 10 +++---
 crates/editor/src/editor_tests.rs | 41 +++++++++++++++++++---
 crates/editor/src/element.rs      |  1 +
 5 files changed, 75 insertions(+), 37 deletions(-)

diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs
index 270edbed1d18d7..f0b9cfb4f5da1f 100644
--- a/crates/editor/src/actions.rs
+++ b/crates/editor/src/actions.rs
@@ -856,6 +856,8 @@ actions!(
         Backtab,
         /// Toggles a bookmark at the current line.
         ToggleBookmark,
+        /// Toggles a bookmark at the current line, prompting for a label when adding one.
+        ToggleBookmarkWithLabel,
         /// Edits the bookmark's label at the current line.
         EditBookmark,
         /// Toggles a breakpoint at the current line.
diff --git a/crates/editor/src/bookmarks.rs b/crates/editor/src/bookmarks.rs
index e2d1c570d4ea39..12a899f398f777 100644
--- a/crates/editor/src/bookmarks.rs
+++ b/crates/editor/src/bookmarks.rs
@@ -13,7 +13,7 @@ use workspace::{Workspace, searchable::Direction};
 use crate::display_map::DisplayRow;
 use crate::{
     EditBookmark, Editor, GoToNextBookmark, GoToPreviousBookmark, MultibufferSelectionMode,
-    SelectionEffects, ToggleBookmark, ViewBookmarks, scroll::Autoscroll,
+    SelectionEffects, ToggleBookmark, ToggleBookmarkWithLabel, ViewBookmarks, scroll::Autoscroll,
 };
 
 #[derive(Clone, Debug)]
@@ -46,6 +46,24 @@ impl Editor {
         _: &ToggleBookmark,
         window: &mut Window,
         cx: &mut Context,
+    ) {
+        self.toggle_bookmark_impl(false, window, cx);
+    }
+
+    pub fn toggle_bookmark_with_label(
+        &mut self,
+        _: &ToggleBookmarkWithLabel,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        self.toggle_bookmark_impl(true, window, cx);
+    }
+
+    fn toggle_bookmark_impl(
+        &mut self,
+        with_label: bool,
+        window: &mut Window,
+        cx: &mut Context,
     ) {
         let Some(bookmark_store) = self.bookmark_store.clone() else {
             return;
@@ -91,34 +109,27 @@ impl Editor {
         if absent_targets.is_empty() {
             // All cursors are on existing bookmarks, remove all bookmarks.
             self.toggle_bookmarks(exist_targets, String::new(), cx);
-        } else {
-            // Only add new ones and leave existing ones unchanged.
+        } else if with_label {
+            // Only add new ones (prompting for a label) and leave existing ones unchanged.
             self.add_toggle_bookmark_blocks(absent_targets, bookmark_store, window, cx);
+        } else {
+            // Only add new (unnamed) bookmarks and leave existing ones unchanged.
+            self.toggle_bookmarks(absent_targets, String::new(), cx);
         }
 
         cx.notify();
     }
 
-    pub fn toggle_bookmark_at_row(
-        &mut self,
-        row: DisplayRow,
-        window: &mut Window,
-        cx: &mut Context,
-    ) {
+    pub fn toggle_bookmark_at_row(&mut self, row: DisplayRow, cx: &mut Context) {
         let display_snapshot = self.display_snapshot(cx);
         let point = display_snapshot.display_point_to_point(row.as_display_point(), Bias::Left);
         let buffer_snapshot = self.buffer.read(cx).snapshot(cx);
         let anchor = buffer_snapshot.anchor_before(point);
 
-        self.toggle_bookmark_at_anchor(anchor, window, cx);
+        self.toggle_bookmark_at_anchor(anchor, cx);
     }
 
-    pub fn toggle_bookmark_at_anchor(
-        &mut self,
-        anchor: Anchor,
-        window: &mut Window,
-        cx: &mut Context,
-    ) {
+    pub fn toggle_bookmark_at_anchor(&mut self, anchor: Anchor, cx: &mut Context) {
         let buffer_snapshot = self.buffer.read(cx).snapshot(cx);
         let Some((position, _)) = buffer_snapshot.anchor_to_buffer_anchor(anchor) else {
             return;
@@ -131,18 +142,9 @@ impl Editor {
             return;
         };
 
-        let target = BookmarkTarget {
-            buffer,
-            anchor,
-            buffer_anchor: position,
-        };
-        if Self::bookmark_exists_for_target(&bookmark_store, &target, cx) {
-            bookmark_store.update(cx, |bookmark_store, cx| {
-                bookmark_store.toggle_bookmark(target.buffer, position, String::new(), cx);
-            });
-        } else {
-            self.add_toggle_bookmark_blocks(vec![target], bookmark_store, window, cx)
-        }
+        bookmark_store.update(cx, |bookmark_store, cx| {
+            bookmark_store.toggle_bookmark(buffer, position, String::new(), cx);
+        });
 
         cx.notify();
     }
diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs
index 9437b8899b7a85..12138cb7f92ebe 100644
--- a/crates/editor/src/editor.rs
+++ b/crates/editor/src/editor.rs
@@ -3990,8 +3990,8 @@ impl Editor {
             .size(ui::ButtonSize::None)
             .icon_color(Color::Info)
             .style(ButtonStyle::Transparent)
-            .on_click(cx.listener(move |editor, _, window, cx| {
-                editor.toggle_bookmark_at_row(row, window, cx);
+            .on_click(cx.listener(move |editor, _, _window, cx| {
+                editor.toggle_bookmark_at_row(row, cx);
             }))
             .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| {
                 editor.set_gutter_context_menu(row, None, event.position(), window, cx);
@@ -4282,10 +4282,10 @@ impl Editor {
                 .separator()
                 .entry(set_bookmark_msg, Some(ToggleBookmark.boxed_clone()), {
                     let weak_editor = weak_editor.clone();
-                    move |window, cx| {
+                    move |_window, cx| {
                         weak_editor
                             .update(cx, |this, cx| {
-                                this.toggle_bookmark_at_anchor(anchor, window, cx);
+                                this.toggle_bookmark_at_anchor(anchor, cx);
                             })
                             .log_err();
                     }
@@ -4481,7 +4481,7 @@ impl Editor {
                     };
 
                     match intent {
-                        Intent::SetBookmark => editor.toggle_bookmark_at_row(row, window, cx),
+                        Intent::SetBookmark => editor.toggle_bookmark_at_row(row, cx),
                         Intent::SetBreakpoint => editor.edit_breakpoint_at_anchor(
                             position,
                             Breakpoint::new_standard(),
diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs
index 6b43dff2cc9af1..de1d6905205a3a 100644
--- a/crates/editor/src/editor_tests.rs
+++ b/crates/editor/src/editor_tests.rs
@@ -29140,6 +29140,13 @@ impl BookmarkTestContext {
             });
     }
 
+    fn toggle_bookmark_with_label(&mut self) {
+        self.editor
+            .update_in(&mut self.cx, |editor: &mut Editor, window, cx| {
+                editor.toggle_bookmark_with_label(&actions::ToggleBookmarkWithLabel, window, cx);
+            });
+    }
+
     fn confirm_bookmark_prompt(&mut self, label: &str) {
         if !label.is_empty() {
             self.cx.simulate_input(label);
@@ -29149,7 +29156,7 @@ impl BookmarkTestContext {
     }
 
     fn add_bookmark_with_label(&mut self, label: &str) {
-        self.toggle_bookmark();
+        self.toggle_bookmark_with_label();
         self.confirm_bookmark_prompt(label);
     }
 
@@ -29204,13 +29211,39 @@ async fn test_bookmark_toggling(cx: &mut TestAppContext) {
 }
 
 #[gpui::test]
-async fn test_bookmark_toggling_with_multiple_selections(cx: &mut TestAppContext) {
+async fn test_bookmark_toggling_unnamed_with_multiple_selections(cx: &mut TestAppContext) {
     let mut ctx =
         BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await;
 
     ctx.select_rows(&[0, 1, 2]);
     ctx.toggle_bookmark();
 
+    ctx.assert_prompt_block_count(0);
+    ctx.assert_bookmarked_file_count(1);
+    ctx.assert_bookmark_labels(vec![(0, ""), (1, ""), (2, "")]);
+
+    ctx.select_rows(&[0, 1, 2, 3]);
+    ctx.toggle_bookmark();
+
+    ctx.assert_prompt_block_count(0);
+    ctx.assert_bookmark_rows(vec![0, 1, 2, 3]);
+
+    ctx.select_rows(&[0, 1, 2, 3]);
+    ctx.toggle_bookmark();
+
+    ctx.assert_prompt_block_count(0);
+    ctx.assert_bookmarked_file_count(0);
+    ctx.assert_bookmark_rows(vec![]);
+}
+
+#[gpui::test]
+async fn test_bookmark_toggling_with_label_with_multiple_selections(cx: &mut TestAppContext) {
+    let mut ctx =
+        BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await;
+
+    ctx.select_rows(&[0, 1, 2]);
+    ctx.toggle_bookmark_with_label();
+
     ctx.assert_prompt_block_count(3);
     ctx.assert_bookmarked_file_count(0);
 
@@ -29229,7 +29262,7 @@ async fn test_bookmark_toggling_with_multiple_selections(cx: &mut TestAppContext
     ]);
 
     ctx.select_rows(&[0, 1, 2, 3]);
-    ctx.toggle_bookmark();
+    ctx.toggle_bookmark_with_label();
 
     ctx.assert_prompt_block_count(1);
     ctx.assert_bookmark_labels(vec![
@@ -29249,7 +29282,7 @@ async fn test_bookmark_toggling_with_multiple_selections(cx: &mut TestAppContext
     ]);
 
     ctx.select_rows(&[0, 1, 2, 3]);
-    ctx.toggle_bookmark();
+    ctx.toggle_bookmark_with_label();
 
     ctx.assert_prompt_block_count(0);
     ctx.assert_bookmarked_file_count(0);
diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs
index 81ef8b6a9d8472..0e4cb930dcb1e2 100644
--- a/crates/editor/src/element.rs
+++ b/crates/editor/src/element.rs
@@ -538,6 +538,7 @@ impl EditorElement {
         register_action(editor, window, Editor::spawn_nearest_task);
         register_action(editor, window, Editor::open_selections_in_multibuffer);
         register_action(editor, window, Editor::toggle_bookmark);
+        register_action(editor, window, Editor::toggle_bookmark_with_label);
         register_action(editor, window, Editor::edit_bookmark);
         register_action(editor, window, Editor::go_to_next_bookmark);
         register_action(editor, window, Editor::go_to_previous_bookmark);

From e4e2c9d3d52e3b35c406ebea82cbfc6c0399f2ff Mon Sep 17 00:00:00 2001
From: ozacod <47009516+ozacod@users.noreply.github.com>
Date: Wed, 1 Jul 2026 13:32:30 +0300
Subject: [PATCH 011/422] text_finder: Seed last query and filters to make
 quick edits easier (#59849)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

## Summary

It is incremental step to solve issue #59825.

This PR addresses the need for facilitating quick edits for matches
obtained by ‘text_finder’. This makes the text finder remember the last
query, so you can jump to a match, make a quick edit, and reopen the
finder to the same results instead of typing the same query again.


## Problem

A common flow is, open the text editor, then jump to first match, edit
that file, then reopen the finder to continue for next matches. But on
the reopen, the query was seeded from under the cursor. And after
editing, the cursor is usually sitting on an unrelated word, and
previous search was lost. The root cause is priority, the word under the
cursor was seeding the query. 



## Solution

Reorder query seeding so last query outranks the cursor word, then
cursor word can be dropped entirely, since last query is prioritized
over the word under cursor, the last query always wins, so checking the
cursor word afterwards is dead code. And JetBrains makes the same
choice, entirely ignores word on the cursor for seeding query. Explicit
selection still outranks the last query, since selecting text is usually
a deliberate choice.

### Before

1- Active project search query (if any)
2- Active buffer search query (if any)
3- Selected text or word under the cursor
4- Empty



### After
1- Active project search query (if any)
2- Active buffer search query (if any)
3- Selected text
4- Last query (of this project)
5- Empty



With updated order, this friction disappears (the same order is also
observed in JetBrains). To make the last query persistent, it is stored
per project in the database along with the active filters (case
sensitive, whole word, regex), so they also survive reopening the
project.


## Testing

- Manually verified the seed priority order between the options.
- Verified the last query is seeded when project is reopened.
- Verified filters are restored regardless of this query order.

Release Notes:

- Improved the text finder to seed the last query and filters to make
quick edits easier.

---------

Co-authored-by: ozacod 
Co-authored-by: Yara 🏳️‍⚧️ 
---
 Cargo.lock                       |   1 +
 crates/search/Cargo.toml         |   1 +
 crates/search/src/text_finder.rs | 156 ++++++++++++++++++++++++++++---
 3 files changed, 143 insertions(+), 15 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 5ab20e6db9fd7a..d3be7e7882d9fd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -16246,6 +16246,7 @@ dependencies = [
  "anyhow",
  "bitflags 2.10.0",
  "collections",
+ "db",
  "editor",
  "file_icons",
  "fs",
diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml
index 56d7e2b37d7e22..af4c5e98379b9c 100644
--- a/crates/search/Cargo.toml
+++ b/crates/search/Cargo.toml
@@ -25,6 +25,7 @@ anyhow.workspace = true
 any_vec.workspace = true
 bitflags.workspace = true
 collections.workspace = true
+db.workspace = true
 editor.workspace = true
 file_icons.workspace = true
 fs.workspace = true
diff --git a/crates/search/src/text_finder.rs b/crates/search/src/text_finder.rs
index 040a45ec3ece54..707587658ce5b9 100644
--- a/crates/search/src/text_finder.rs
+++ b/crates/search/src/text_finder.rs
@@ -1,5 +1,10 @@
 use std::{ops::Range, sync::atomic::Ordering};
 
+use db::{
+    query,
+    sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
+    sqlez_macros::sql,
+};
 use editor::Editor;
 use gpui::{
     App, AppContext, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
@@ -9,16 +14,20 @@ use language::Buffer;
 use picker::Picker;
 
 use project::ProjectPath;
+use settings::SeedQuerySetting;
 use text::Anchor;
 use ui::Window;
-use workspace::{DismissDecision, ModalView, Workspace, searchable::SearchableItemHandle};
+use workspace::{
+    DismissDecision, ModalView, Workspace, WorkspaceDb, WorkspaceId,
+    searchable::SearchableItemHandle,
+};
 
 mod delegate;
 mod render;
 use delegate::{Delegate, matches_to_multibuffer};
 use util::ResultExt as _;
 
-use crate::{ProjectSearchView, text_finder::delegate::PopulateProjectSearch};
+use crate::{ProjectSearchView, SearchOptions, text_finder::delegate::PopulateProjectSearch};
 
 actions!(text_finder, [ToProjectSearch,]);
 
@@ -28,6 +37,87 @@ pub struct TextFinder {
     _subscription: Subscription,
 }
 
+/// Persists the query and active filters of the just-closed Text Finder in the per-project
+/// database, keyed by workspace so the search is restored only for the project it was run in
+/// (mirrors JetBrains' per-project find history). The row is removed automatically when its
+/// workspace is deleted, via the `ON DELETE CASCADE` foreign key. Workspaces without a database
+/// id (not yet persisted) don't participate.
+pub struct TextFinderDb(ThreadSafeConnection);
+
+impl Domain for TextFinderDb {
+    const NAME: &str = stringify!(TextFinderDb);
+
+    const MIGRATIONS: &[&str] = &[sql!(
+        CREATE TABLE text_finder_queries (
+            workspace_id INTEGER PRIMARY KEY,
+            query TEXT NOT NULL,
+            search_options INTEGER NOT NULL DEFAULT 0,
+            FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
+            ON DELETE CASCADE
+        ) STRICT;
+    )];
+}
+
+db::static_connection!(TextFinderDb, [WorkspaceDb]);
+
+impl TextFinderDb {
+    query! {
+        pub async fn set_last_search(workspace_id: WorkspaceId, query: String, search_options: i64) -> Result<()> {
+            INSERT INTO text_finder_queries (workspace_id, query, search_options)
+            VALUES (?1, ?2, ?3)
+            ON CONFLICT(workspace_id) DO UPDATE SET query = ?2, search_options = ?3
+        }
+    }
+
+    query! {
+        pub fn last_search(workspace_id: WorkspaceId) -> Result> {
+            SELECT query, search_options
+            FROM text_finder_queries
+            WHERE workspace_id = ?1
+        }
+    }
+}
+
+/// A query to pre-populate the Text Finder with, plus the search filters to restore alongside
+/// it. `options` carries the workspace's last-used filters when there are any persisted; it is
+/// `None` only the first time the finder is used in a workspace, leaving the filters at their
+/// setting-derived defaults.
+pub(crate) struct SearchSeed {
+    query: String,
+    options: Option,
+}
+
+fn store_last_search(
+    workspace_id: Option,
+    query: String,
+    options: SearchOptions,
+    cx: &App,
+) {
+    let Some(workspace_id) = workspace_id else {
+        return;
+    };
+    let db = TextFinderDb::global(cx);
+    let search_options = options.bits() as i64;
+    db::write_and_log(cx, move || async move {
+        db.set_last_search(workspace_id, query, search_options)
+            .await
+    });
+}
+
+fn load_last_search(workspace_id: Option, cx: &App) -> Option {
+    let (query, search_options) = TextFinderDb::global(cx)
+        .last_search(workspace_id?)
+        .log_err()
+        .flatten()?;
+    if query.is_empty() {
+        return None;
+    }
+    Some(SearchSeed {
+        query,
+        options: Some(SearchOptions::from_bits_truncate(search_options as u8)),
+    })
+}
+
 pub fn init(cx: &mut App) {
     cx.observe_new(TextFinder::register).detach();
 }
@@ -206,14 +296,31 @@ impl TextFinder {
             .update(cx, |p, _| p.delegate.in_progress_search.take_connected())
     }
 
-    /// The query to pre-populate the text finder with, sourced from the active
-    /// item in priority order, mirroring how project search seeds itself: an
-    /// active project search's query, then a focused buffer search bar's query,
-    /// then the word under the cursor (honoring `seed_search_query_from_cursor`).
+    /// Guess the query the user probably wants for pre-populating the search input.
     fn seed_query(
         workspace: &mut Workspace,
         window: &mut Window,
         cx: &mut Context,
+    ) -> Option {
+        let last_search = load_last_search(workspace.database_id(), cx);
+        let options = last_search.as_ref().and_then(|seed| seed.options);
+
+        let query = Self::active_item_query(workspace, window, cx)
+            .or_else(|| last_search.map(|seed| seed.query))?;
+
+        Some(SearchSeed { query, options })
+    }
+
+    /// The query to seed from the active item, if any.
+    ///
+    /// Only an explicit selection seeds from the editor; the bare word under the cursor is
+    /// ignored. Confirming a match jumps to (and places the cursor on) it, so seeding from the
+    /// cursor on reopen would clobber the search you were in the middle of, whereas a deliberate
+    /// selection (e.g. a double-click) is a clear signal to search for that text.
+    fn active_item_query(
+        workspace: &mut Workspace,
+        window: &mut Window,
+        cx: &mut Context,
     ) -> Option {
         let item = workspace.active_item(cx)?;
 
@@ -230,13 +337,18 @@ impl TextFinder {
             return Some(query);
         }
 
-        let editor = item.act_as::(cx)?;
-        let query = editor.query_suggestion(None, window, cx);
-        (!query.is_empty()).then_some(query)
+        if let Some(editor) = item.act_as::(cx) {
+            let query = editor.query_suggestion(Some(SeedQuerySetting::Selection), window, cx);
+            if !query.is_empty() {
+                return Some(query);
+            }
+        }
+
+        None
     }
 
-    pub fn open(
-        seed_query: Option,
+    pub(crate) fn open(
+        seed_query: Option,
         window: &mut Window,
         cx: &mut Context,
     ) -> Task<()> {
@@ -260,7 +372,7 @@ impl TextFinder {
 
     fn new(
         delegate: Delegate,
-        seed_query: Option,
+        seed_query: Option,
         window: &mut Window,
         cx: &mut Context,
     ) -> Self {
@@ -272,8 +384,12 @@ impl TextFinder {
         picker.update(cx, |picker, cx| {
             picker.delegate.focus_handle = picker_focus_handle.clone();
             picker.delegate.hook_up_any_ongoing_search(picker_weak, cx);
-            if let Some(seed_query) = seed_query.as_deref() {
-                picker.set_query(seed_query, window, cx);
+            if let Some(seed_query) = seed_query {
+                // Restore filters before seeding the query so the initial search runs with them.
+                if let Some(options) = seed_query.options {
+                    picker.delegate.search_options = options;
+                }
+                picker.set_query(&seed_query.query, window, cx);
                 picker.select_query(window, cx);
             }
         });
@@ -310,8 +426,18 @@ impl ModalView for TextFinder {
     fn on_before_dismiss(
         &mut self,
         _window: &mut Window,
-        _cx: &mut Context,
+        cx: &mut Context,
     ) -> DismissDecision {
+        let picker = self.picker.read(cx);
+        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);
+        }
         DismissDecision::Dismiss(true)
     }
 }

From eef824cce5d6cfbac34d9c8af6d84ba7132dcef2 Mon Sep 17 00:00:00 2001
From: Bennet Bo Fenner 
Date: Wed, 1 Jul 2026 12:52:33 +0200
Subject: [PATCH 012/422] agent_ui: Open MCP settings UI instead of modal
 (#60188)

This makes it so that we show open the settings UI instead of the modal
when adding an MCP server when clicking on `Add server`:

image

We still show the modal when installing an extension. Since we're
hopefully replacing MCP extensions with MCP registry support soon, we
can leave it as is for now.

Release Notes:

- N/A
---
 .../configure_context_server_modal.rs         | 192 +++---------------
 crates/agent_ui/src/agent_panel.rs            |  24 ++-
 crates/agent_ui/src/agent_ui.rs               |  51 +----
 docs/src/ai/mcp.md                            |   4 +-
 4 files changed, 45 insertions(+), 226 deletions(-)

diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs
index 843a35068be21d..6a32b488605f6b 100644
--- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs
+++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs
@@ -6,8 +6,7 @@ use editor::{Editor, EditorElement, EditorStyle};
 use extension_host::ExtensionStore;
 use gpui::{
     AsyncWindowContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle,
-    Subscription, Task, TaskExt, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity,
-    prelude::*,
+    Subscription, Task, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*,
 };
 use language::{Language, LanguageRegistry};
 use markdown::{Markdown, MarkdownElement, MarkdownStyle};
@@ -32,12 +31,7 @@ use ui::{
 use util::ResultExt as _;
 use workspace::{ModalView, Workspace};
 
-use crate::{AddContextServer, ContextServerType};
-
 enum ConfigurationTarget {
-    New {
-        server_type: ContextServerType,
-    },
     Existing {
         id: ContextServerId,
         command: ContextServerCommand,
@@ -56,14 +50,15 @@ enum ConfigurationTarget {
     },
 }
 
+enum ExistingServerType {
+    Local,
+    Remote,
+}
+
 enum ConfigurationSource {
-    New {
-        editor: Entity,
-        server_type: ContextServerType,
-    },
     Existing {
         editor: Entity,
-        server_type: ContextServerType,
+        server_type: ExistingServerType,
     },
     Extension {
         id: ContextServerId,
@@ -79,10 +74,6 @@ impl ConfigurationSource {
         !matches!(self, ConfigurationSource::Extension { editor: None, .. })
     }
 
-    fn is_new(&self) -> bool {
-        matches!(self, ConfigurationSource::New { .. })
-    }
-
     fn from_target(
         target: ConfigurationTarget,
         language_registry: Arc,
@@ -109,18 +100,6 @@ impl ConfigurationSource {
         }
 
         match target {
-            ConfigurationTarget::New { server_type } => ConfigurationSource::New {
-                editor: create_editor(
-                    match server_type {
-                        ContextServerType::Remote => context_server_http_input(None),
-                        ContextServerType::Local => context_server_input(None),
-                    },
-                    jsonc_language,
-                    window,
-                    cx,
-                ),
-                server_type,
-            },
             ConfigurationTarget::Existing { id, command } => ConfigurationSource::Existing {
                 editor: create_editor(
                     context_server_input(Some((id, command))),
@@ -128,7 +107,7 @@ impl ConfigurationSource {
                     window,
                     cx,
                 ),
-                server_type: ContextServerType::Local,
+                server_type: ExistingServerType::Local,
             },
             ConfigurationTarget::ExistingHttp {
                 id,
@@ -142,7 +121,7 @@ impl ConfigurationSource {
                     window,
                     cx,
                 ),
-                server_type: ContextServerType::Remote,
+                server_type: ExistingServerType::Remote,
             },
 
             ConfigurationTarget::Extension {
@@ -180,15 +159,11 @@ impl ConfigurationSource {
 
     fn output(&self, cx: &mut App) -> Result<(ContextServerId, ContextServerSettings)> {
         match self {
-            ConfigurationSource::New {
-                editor,
-                server_type,
-            }
-            | ConfigurationSource::Existing {
+            ConfigurationSource::Existing {
                 editor,
                 server_type,
             } => match *server_type {
-                ContextServerType::Remote => {
+                ExistingServerType::Remote => {
                     parse_http_input(&editor.read(cx).text(cx)).map(|(id, url, auth, oauth)| {
                         (
                             id,
@@ -202,7 +177,7 @@ impl ConfigurationSource {
                         )
                     })
                 }
-                ContextServerType::Local => {
+                ExistingServerType::Local => {
                     parse_input(&editor.read(cx).text(cx)).map(|(id, command)| {
                         (
                             id,
@@ -459,13 +434,10 @@ impl ConfigureContextServerModal {
         target: &ConfigurationTarget,
         cx: &App,
     ) -> State {
-        let Some(server_id) = (match target {
+        let server_id = match target {
             ConfigurationTarget::Existing { id, .. }
             | ConfigurationTarget::ExistingHttp { id, .. }
-            | ConfigurationTarget::Extension { id, .. } => Some(id),
-            ConfigurationTarget::New { .. } => None,
-        }) else {
-            return State::Idle;
+            | ConfigurationTarget::Extension { id, .. } => id,
         };
 
         match context_server_store.read(cx).status_for_server(server_id) {
@@ -490,32 +462,6 @@ impl ConfigureContextServerModal {
         }
     }
 
-    pub fn register(
-        workspace: &mut Workspace,
-        language_registry: Arc,
-        _window: Option<&mut Window>,
-        _cx: &mut Context,
-    ) {
-        workspace.register_action({
-            move |_workspace, action: &AddContextServer, window, cx| {
-                let workspace_handle = cx.weak_entity();
-                let language_registry = language_registry.clone();
-                let server_type = action.context_server_type;
-                window
-                    .spawn(cx, async move |cx| {
-                        Self::show_modal(
-                            ConfigurationTarget::New { server_type },
-                            language_registry,
-                            workspace_handle,
-                            cx,
-                        )
-                        .await
-                    })
-                    .detach_and_log_err(cx);
-            }
-        });
-    }
-
     pub fn show_modal_for_existing_server(
         server_id: ContextServerId,
         language_registry: Arc,
@@ -600,12 +546,11 @@ impl ConfigureContextServerModal {
                     workspace: workspace_handle,
                     state: Self::initial_state(&context_server_store, &target, cx),
 
-                    original_server_id: match &target {
-                        ConfigurationTarget::Existing { id, .. } => Some(id.clone()),
-                        ConfigurationTarget::ExistingHttp { id, .. } => Some(id.clone()),
-                        ConfigurationTarget::Extension { id, .. } => Some(id.clone()),
-                        ConfigurationTarget::New { .. } => None,
-                    },
+                    original_server_id: Some(match &target {
+                        ConfigurationTarget::Existing { id, .. }
+                        | ConfigurationTarget::ExistingHttp { id, .. }
+                        | ConfigurationTarget::Extension { id, .. } => id.clone(),
+                    }),
                     source: ConfigurationSource::from_target(
                         target,
                         language_registry,
@@ -835,7 +780,6 @@ impl ModalView for ConfigureContextServerModal {}
 impl Focusable for ConfigureContextServerModal {
     fn focus_handle(&self, cx: &App) -> FocusHandle {
         match &self.source {
-            ConfigurationSource::New { editor, .. } => editor.focus_handle(cx),
             ConfigurationSource::Existing { editor, .. } => editor.focus_handle(cx),
             ConfigurationSource::Extension { editor, .. } => editor
                 .as_ref()
@@ -850,7 +794,6 @@ impl EventEmitter for ConfigureContextServerModal {}
 impl ConfigureContextServerModal {
     fn render_modal_header(&self) -> ModalHeader {
         let text: SharedString = match &self.source {
-            ConfigurationSource::New { .. } => "Add MCP Server".into(),
             ConfigurationSource::Existing { .. } => "Configure MCP Server".into(),
             ConfigurationSource::Extension { id, .. } => format!("Configure {}", id.0).into(),
         };
@@ -881,79 +824,8 @@ impl ConfigureContextServerModal {
         }
     }
 
-    fn render_tab_bar(&self, cx: &mut Context) -> Option {
-        let is_http = match &self.source {
-            ConfigurationSource::New { server_type, .. } => {
-                *server_type == ContextServerType::Remote
-            }
-            _ => return None,
-        };
-
-        let tab = |label: &'static str, active: bool| {
-            div()
-                .id(label)
-                .cursor_pointer()
-                .p_1()
-                .text_sm()
-                .border_b_1()
-                .when_else(
-                    active,
-                    |this| this.border_color(cx.theme().colors().border_focused),
-                    |this| {
-                        this.border_color(gpui::transparent_black())
-                            .text_color(cx.theme().colors().text_muted)
-                            .hover(|s| s.text_color(cx.theme().colors().text))
-                    },
-                )
-                .child(label)
-        };
-
-        Some(
-            h_flex()
-                .pt_1()
-                .mb_2p5()
-                .gap_1()
-                .border_b_1()
-                .border_color(cx.theme().colors().border.opacity(0.5))
-                .child(
-                    tab("Local", !is_http).on_click(cx.listener(|this, _, window, cx| {
-                        if let ConfigurationSource::New {
-                            editor,
-                            server_type,
-                        } = &mut this.source
-                            && *server_type != ContextServerType::Local
-                        {
-                            *server_type = ContextServerType::Local;
-                            let new_text = context_server_input(None);
-                            editor.update(cx, |editor, cx| {
-                                editor.set_text(new_text, window, cx);
-                            });
-                        }
-                    })),
-                )
-                .child(
-                    tab("Remote", is_http).on_click(cx.listener(|this, _, window, cx| {
-                        if let ConfigurationSource::New {
-                            editor,
-                            server_type,
-                        } = &mut this.source
-                            && *server_type != ContextServerType::Remote
-                        {
-                            *server_type = ContextServerType::Remote;
-                            let new_text = context_server_http_input(None);
-                            editor.update(cx, |editor, cx| {
-                                editor.set_text(new_text, window, cx);
-                            });
-                        }
-                    })),
-                )
-                .into_any_element(),
-        )
-    }
-
     fn render_modal_content(&self, cx: &App) -> AnyElement {
         let editor = match &self.source {
-            ConfigurationSource::New { editor, .. } => editor,
             ConfigurationSource::Existing { editor, .. } => editor,
             ConfigurationSource::Extension { editor, .. } => {
                 let Some(editor) = editor else {
@@ -1053,24 +925,15 @@ impl ConfigureContextServerModal {
                         ),
                     )
                     .children(self.source.has_configuration_options().then(|| {
-                        Button::new(
-                            "add-server",
-                            if self.source.is_new() {
-                                "Add Server"
-                            } else {
-                                "Configure Server"
-                            },
-                        )
-                        .disabled(is_busy)
-                        .key_binding(
-                            KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
-                                .map(|kb| kb.size(rems_from_px(12.))),
-                        )
-                        .on_click(
-                            cx.listener(|this, _event, _window, cx| {
+                        Button::new("configure-server", "Configure Server")
+                            .disabled(is_busy)
+                            .key_binding(
+                                KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
+                                    .map(|kb| kb.size(rems_from_px(12.))),
+                            )
+                            .on_click(cx.listener(|this, _event, _window, cx| {
                                 this.confirm(&menu::Confirm, cx)
-                            }),
-                        )
+                            }))
                     })),
             )
     }
@@ -1278,7 +1141,6 @@ impl Render for ConfigureContextServerModal {
                                         .overflow_y_scroll()
                                         .track_scroll(&self.scroll_handle)
                                         .child(self.render_modal_description(window, cx))
-                                        .children(self.render_tab_bar(cx))
                                         .child(self.render_modal_content(cx))
                                         .child(match &self.state {
                                             State::Idle => div(),
diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs
index 01b380044acfee..7fe2d2c0b9f2db 100644
--- a/crates/agent_ui/src/agent_panel.rs
+++ b/crates/agent_ui/src/agent_panel.rs
@@ -44,19 +44,19 @@ use crate::terminal_thread_metadata_store::{
 };
 use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore, ThreadMetadataStoreEvent};
 use crate::{
-    AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, Follow,
-    LoadThreadFromClipboard, NewTerminalThread, NewThread, OpenActiveThreadAsMarkdown,
-    OpenAgentDiff, ResetFastModeWarnings, ResetTrialEndUpsell, ResetTrialUpsell,
-    ShowAllSidebarThreadMetadata, ShowThreadMetadata, ToggleNewThreadMenu, ToggleOptionsMenu,
+    Agent, AgentInitialContent, AgentThreadSource, ExternalSourcePrompt, NewExternalAgentThread,
+    NewNativeAgentThreadFromSummary,
+};
+use crate::{
+    AgentDiffPane, ConversationView, CopyThreadToClipboard, Follow, LoadThreadFromClipboard,
+    NewTerminalThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, ResetFastModeWarnings,
+    ResetTrialEndUpsell, ResetTrialUpsell, ShowAllSidebarThreadMetadata, ShowThreadMetadata,
+    ToggleNewThreadMenu, ToggleOptionsMenu,
     conversation_view::{
         AcpThreadViewEvent, RootThreadUpdated, ThreadView, reset_fast_mode_warnings,
     },
     ui::{AgentNotification, AgentNotificationEvent, EndTrialUpsell},
 };
-use crate::{
-    Agent, AgentInitialContent, AgentThreadSource, ExternalSourcePrompt, NewExternalAgentThread,
-    NewNativeAgentThreadFromSummary,
-};
 use agent_settings::AgentSettings;
 use ai_onboarding::AgentPanelOnboarding;
 use anyhow::{Context as _, Result, anyhow};
@@ -5560,7 +5560,13 @@ impl AgentPanel {
                         if !showing_terminal {
                             menu = menu
                                 .header("MCP Servers")
-                                .action("Add Server…", Box::new(AddContextServer::local()))
+                                .action(
+                                    "Add Server…",
+                                    Box::new(zed_actions::OpenSettingsAt {
+                                        path: "context_servers".to_string(),
+                                        target: None,
+                                    }),
+                                )
                                 .action(
                                     "Install New Servers…",
                                     Box::new(zed_actions::Extensions {
diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs
index ad5940d21c0e5b..1949a5965a86e8 100644
--- a/crates/agent_ui/src/agent_ui.rs
+++ b/crates/agent_ui/src/agent_ui.rs
@@ -67,7 +67,7 @@ use std::any::TypeId;
 use std::path::{Path, PathBuf};
 use workspace::Workspace;
 
-use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal};
+use crate::agent_configuration::ManageProfilesModal;
 pub use crate::agent_connection_store::{ActiveAcpConnection, AgentConnectionStore};
 pub use crate::agent_panel::{
     AgentPanel, AgentPanelEvent, AgentPanelTerminalInfo, MaxIdleRetainedThreads, TerminalId,
@@ -353,49 +353,6 @@ pub struct ToggleCommandPattern {
 #[serde(deny_unknown_fields)]
 pub struct NewThread;
 
-/// The kind of context server to configure when adding a new one.
-#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
-#[serde(rename_all = "snake_case")]
-pub enum ContextServerType {
-    /// A context server that runs locally via stdin/stdout.
-    #[default]
-    Local,
-    /// A context server that is connected to over HTTP.
-    Remote,
-}
-
-/// Adds a context server to the configuration.
-#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
-#[action(namespace = agent)]
-#[serde(deny_unknown_fields)]
-pub struct AddContextServer {
-    /// The kind of context server to add.
-    #[serde(default)]
-    pub context_server_type: ContextServerType,
-}
-
-impl Default for AddContextServer {
-    fn default() -> Self {
-        Self::local()
-    }
-}
-
-impl AddContextServer {
-    /// Returns an action that adds a local (stdin/stdout) context server.
-    pub fn local() -> Self {
-        Self {
-            context_server_type: ContextServerType::Local,
-        }
-    }
-
-    /// Returns an action that adds a remote (HTTP) context server.
-    pub fn remote() -> Self {
-        Self {
-            context_server_type: ContextServerType::Remote,
-        }
-    }
-}
-
 /// Creates a new external agent conversation thread.
 #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
 #[action(namespace = agent)]
@@ -628,16 +585,12 @@ pub fn init(
         init_language_model_settings(cx);
     }
     agent_panel::init(cx);
-    context_server_configuration::init(language_registry.clone(), fs.clone(), cx);
+    context_server_configuration::init(language_registry, fs.clone(), cx);
     thread_metadata_store::init(cx);
     terminal_thread_metadata_store::init(cx);
 
     inline_assistant::init(fs.clone(), prompt_builder.clone(), cx);
     terminal_inline_assistant::init(fs.clone(), prompt_builder, cx);
-    cx.observe_new(move |workspace, window, cx| {
-        ConfigureContextServerModal::register(workspace, language_registry.clone(), window, cx)
-    })
-    .detach();
     cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
         workspace.register_action(
             move |workspace: &mut Workspace,
diff --git a/docs/src/ai/mcp.md b/docs/src/ai/mcp.md
index 83c456be0bbd5b..777fd0e9b085be 100644
--- a/docs/src/ai/mcp.md
+++ b/docs/src/ai/mcp.md
@@ -51,7 +51,7 @@ Popular servers available as an extension include:
 ### As Custom Servers
 
 Creating an extension is not the only way to use MCP servers in Zed.
-You can connect both local and remote MCP servers easily utilizing the MCP server modal, which you can open by invoking the {#action agent::AddContextServer} action. Your specified configuration will create entries in your settings file (which you can open with {#action zed::OpenSettingsFile}) similar to the ones below:
+You can connect both local and remote MCP servers from **Settings → AI → MCP Servers** (also accessible via the {#action agent::OpenSettings} action, then selecting `MCP Servers`). Click `Add Server` in the page header, then choose `Add Local Server` or `Add Remote Server`. Your specified configuration will create entries in your settings file (which you can open with {#action zed::OpenSettingsFile}) similar to the ones below:
 
 ```json [settings]
 {
@@ -72,8 +72,6 @@ You can connect both local and remote MCP servers easily utilizing the MCP serve
 }
 ```
 
-Alternatively, you can open the modal from **Settings → AI → MCP Servers** (also accessible via the {#action agent::OpenSettings} action, then selecting `MCP Servers`) and clicking the `Add Server` button in the page header, then choosing `Add Local Server` or `Add Remote Server`.
-
 > Note: When a remote MCP server has no configured `"Authorization"` header, Zed will prompt you to authenticate yourself against the MCP server using the standard MCP OAuth flow.
 
 ## Using MCP Servers

From fa66442a0110bd2fc73bc2af9ecc017b23088663 Mon Sep 17 00:00:00 2001
From: Ben Brandt 
Date: Wed, 1 Jul 2026 15:10:14 +0200
Subject: [PATCH 013/422] acp: Add experimental elicitation UI support (#58927)

Behind a feature flag as we iterate on the RFD

Release Notes:

- N/A

---------

Co-authored-by: Bennet Bo Fenner 
Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
---
 crates/acp_thread/src/acp_thread.rs           | 1759 ++++++++++++++++-
 crates/acp_thread/src/connection.rs           |    9 +-
 crates/agent_servers/src/acp.rs               |  634 +++++-
 crates/agent_ui/src/agent_diff.rs             |    2 +
 crates/agent_ui/src/conversation_view.rs      | 1190 ++++++++++-
 .../src/conversation_view/elicitation.rs      | 1642 +++++++++++++++
 .../conversation_view/thread_search_bar.rs    |    1 +
 .../src/conversation_view/thread_view.rs      |  326 ++-
 crates/agent_ui/src/entry_view_state.rs       |  103 +-
 9 files changed, 5548 insertions(+), 118 deletions(-)
 create mode 100644 crates/agent_ui/src/conversation_view/elicitation.rs

diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs
index e85a0bd48fb95f..87c9dd1c42c5de 100644
--- a/crates/acp_thread/src/acp_thread.rs
+++ b/crates/acp_thread/src/acp_thread.rs
@@ -387,10 +387,366 @@ pub enum AgentThreadEntry {
     UserMessage(UserMessage),
     AssistantMessage(AssistantMessage),
     ToolCall(ToolCall),
+    Elicitation(ElicitationEntryId),
     CompletedPlan(Vec),
     ContextCompaction(ContextCompaction),
 }
 
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct ElicitationEntryId(pub Arc);
+
+#[derive(Debug)]
+pub struct Elicitation {
+    pub id: ElicitationEntryId,
+    pub request: acp::CreateElicitationRequest,
+    pub status: ElicitationStatus,
+}
+
+#[derive(Debug)]
+pub enum ElicitationStatus {
+    Pending {
+        respond_tx: oneshot::Sender,
+    },
+    Accepted,
+    Declined,
+    Canceled,
+    Completed,
+}
+
+#[derive(Clone, Debug)]
+pub enum ElicitationStoreEvent {
+    ElicitationRequested(ElicitationEntryId),
+    ElicitationResponded(ElicitationEntryId),
+    ElicitationUpdated(ElicitationEntryId),
+}
+
+#[derive(Default)]
+pub struct ElicitationStore {
+    elicitations: Vec,
+}
+
+impl EventEmitter for ElicitationStore {}
+
+impl ElicitationStore {
+    pub fn elicitations(&self) -> &[Elicitation] {
+        &self.elicitations
+    }
+
+    fn validate_request(
+        request: &acp::CreateElicitationRequest,
+        cx: &App,
+    ) -> Result<(), acp::Error> {
+        if !cx.has_flag::() {
+            return Err(
+                acp::Error::invalid_params().data("elicitation support requires the ACP beta flag")
+            );
+        }
+
+        if let acp::ElicitationMode::Url(mode) = &request.mode {
+            url::Url::parse(&mode.url)
+                .map_err(|_| acp::Error::invalid_params().data("invalid elicitation URL"))?;
+        }
+
+        Ok(())
+    }
+
+    fn insert_pending_elicitation(
+        &mut self,
+        request: acp::CreateElicitationRequest,
+    ) -> (
+        ElicitationEntryId,
+        oneshot::Receiver,
+    ) {
+        let (respond_tx, response_rx) = oneshot::channel();
+        let id = ElicitationEntryId(Uuid::new_v4().to_string().into());
+        self.elicitations.push(Elicitation {
+            id: id.clone(),
+            request,
+            status: ElicitationStatus::Pending { respond_tx },
+        });
+        (id, response_rx)
+    }
+
+    fn response_task(
+        id: ElicitationEntryId,
+        response_rx: oneshot::Receiver,
+        cx: &mut Context,
+        emit_responded: impl FnOnce(&mut T, &mut Context, ElicitationEntryId) + 'static,
+    ) -> Task
+    where
+        T: 'static,
+    {
+        cx.spawn(async move |this, cx| {
+            let response = response_rx.await.unwrap_or_else(|oneshot::Canceled| {
+                acp::CreateElicitationResponse::new(acp::ElicitationAction::Cancel)
+            });
+            this.update(cx, |this, cx| emit_responded(this, cx, id))
+                .ok();
+            response
+        })
+    }
+
+    fn respond_to_elicitation_entry(
+        elicitation: &mut Elicitation,
+        response: acp::CreateElicitationResponse,
+    ) -> bool {
+        if !matches!(elicitation.status, ElicitationStatus::Pending { .. }) {
+            return false;
+        }
+        let ElicitationStatus::Pending { respond_tx } = mem::replace(
+            &mut elicitation.status,
+            elicitation_status_for_response(&response),
+        ) else {
+            return false;
+        };
+        respond_tx.send(response).ok();
+        true
+    }
+
+    fn complete_url_elicitation_entry(elicitation: &mut Elicitation) -> bool {
+        let previous_status = mem::replace(&mut elicitation.status, ElicitationStatus::Completed);
+        match previous_status {
+            ElicitationStatus::Pending { respond_tx } => {
+                respond_tx
+                    .send(acp::CreateElicitationResponse::new(
+                        acp::ElicitationAction::Accept(acp::ElicitationAcceptAction::new()),
+                    ))
+                    .ok();
+                true
+            }
+            ElicitationStatus::Accepted => true,
+            ElicitationStatus::Completed => false,
+            previous_status @ (ElicitationStatus::Declined | ElicitationStatus::Canceled) => {
+                elicitation.status = previous_status;
+                false
+            }
+        }
+    }
+
+    fn cancel_elicitation_entry(
+        elicitation: &mut Elicitation,
+        cancel_accepted_url_elicitations: bool,
+    ) -> bool {
+        match mem::replace(&mut elicitation.status, ElicitationStatus::Canceled) {
+            ElicitationStatus::Pending { respond_tx } => {
+                respond_tx
+                    .send(acp::CreateElicitationResponse::new(
+                        acp::ElicitationAction::Cancel,
+                    ))
+                    .ok();
+                true
+            }
+            ElicitationStatus::Accepted
+                if cancel_accepted_url_elicitations
+                    && matches!(&elicitation.request.mode, acp::ElicitationMode::Url(_)) =>
+            {
+                true
+            }
+            previous_status => {
+                elicitation.status = previous_status;
+                false
+            }
+        }
+    }
+
+    fn respond_to_elicitation_by_id(
+        &mut self,
+        id: &ElicitationEntryId,
+        response: acp::CreateElicitationResponse,
+    ) -> bool {
+        let Some((_, elicitation)) = self.elicitation_mut(id) else {
+            return false;
+        };
+        Self::respond_to_elicitation_entry(elicitation, response)
+    }
+
+    fn complete_url_elicitation_by_id(&mut self, id: &ElicitationEntryId) -> bool {
+        let Some((_, elicitation)) = self.elicitation_mut(id) else {
+            return false;
+        };
+        Self::complete_url_elicitation_entry(elicitation)
+    }
+
+    fn cancel_elicitation_by_id(
+        &mut self,
+        id: &ElicitationEntryId,
+        cancel_accepted_url_elicitations: bool,
+    ) -> bool {
+        let Some((_, elicitation)) = self.elicitation_mut(id) else {
+            return false;
+        };
+        Self::cancel_elicitation_entry(elicitation, cancel_accepted_url_elicitations)
+    }
+
+    pub fn request_elicitation(
+        &mut self,
+        request: acp::CreateElicitationRequest,
+        cx: &mut Context,
+    ) -> Result, acp::Error> {
+        self.request_elicitation_with_id(request, cx)
+            .map(|(_, task)| task)
+    }
+
+    pub fn request_elicitation_with_id(
+        &mut self,
+        request: acp::CreateElicitationRequest,
+        cx: &mut Context,
+    ) -> Result<(ElicitationEntryId, Task), acp::Error> {
+        Self::validate_request(&request, cx)?;
+        let (id, response_rx) = self.insert_pending_elicitation(request);
+        cx.emit(ElicitationStoreEvent::ElicitationRequested(id.clone()));
+        cx.notify();
+
+        let task = Self::response_task(id.clone(), response_rx, cx, |_store, cx, id| {
+            cx.emit(ElicitationStoreEvent::ElicitationResponded(id));
+            cx.notify();
+        });
+
+        Ok((id, task))
+    }
+
+    pub fn respond_to_elicitation(
+        &mut self,
+        id: &ElicitationEntryId,
+        response: acp::CreateElicitationResponse,
+        cx: &mut Context,
+    ) {
+        if !self.respond_to_elicitation_by_id(id, response) {
+            return;
+        }
+
+        cx.emit(ElicitationStoreEvent::ElicitationUpdated(id.clone()));
+        cx.notify();
+    }
+
+    pub fn complete_url_elicitation(
+        &mut self,
+        elicitation_id: &acp::ElicitationId,
+        cx: &mut Context,
+    ) {
+        let Some(entry_id) = self.entry_id_for_url_elicitation(elicitation_id) else {
+            return;
+        };
+        if !self.complete_url_elicitation_by_id(&entry_id) {
+            return;
+        }
+
+        cx.emit(ElicitationStoreEvent::ElicitationUpdated(entry_id));
+        cx.notify();
+    }
+
+    pub fn cancel_elicitation(&mut self, id: &ElicitationEntryId, cx: &mut Context) {
+        if !self.cancel_elicitation_by_id(id, true) {
+            return;
+        }
+
+        cx.emit(ElicitationStoreEvent::ElicitationUpdated(id.clone()));
+        cx.notify();
+    }
+
+    pub fn cancel_all(&mut self, cx: &mut Context) {
+        let canceled_ids = self.cancel_pending(|_| true);
+        for id in canceled_ids {
+            cx.emit(ElicitationStoreEvent::ElicitationUpdated(id));
+        }
+        cx.notify();
+    }
+
+    pub fn clear(&mut self, cx: &mut Context) {
+        let canceled_ids = self.cancel_pending(|_| true);
+        self.elicitations.clear();
+        for id in canceled_ids {
+            cx.emit(ElicitationStoreEvent::ElicitationUpdated(id));
+        }
+        cx.notify();
+    }
+
+    pub fn clear_resolved(&mut self, cx: &mut Context) -> Vec {
+        let mut cleared_ids = Vec::new();
+        self.elicitations.retain(|elicitation| {
+            let keep = matches!(
+                (&elicitation.status, &elicitation.request.mode),
+                (ElicitationStatus::Pending { .. }, _)
+                    | (ElicitationStatus::Accepted, acp::ElicitationMode::Url(_))
+            );
+            if !keep {
+                cleared_ids.push(elicitation.id.clone());
+            }
+            keep
+        });
+
+        if !cleared_ids.is_empty() {
+            for id in &cleared_ids {
+                cx.emit(ElicitationStoreEvent::ElicitationUpdated(id.clone()));
+            }
+            cx.notify();
+        }
+
+        cleared_ids
+    }
+
+    pub fn cancel_request(&mut self, request_id: &acp::RequestId, cx: &mut Context) {
+        let canceled_ids = self.cancel_pending(|elicitation| {
+            matches!(
+                elicitation.request.scope(),
+                acp::ElicitationScope::Request(scope) if &scope.request_id == request_id
+            )
+        });
+        for id in canceled_ids {
+            cx.emit(ElicitationStoreEvent::ElicitationUpdated(id));
+        }
+        cx.notify();
+    }
+
+    pub fn elicitation(&self, id: &ElicitationEntryId) -> Option<(usize, &Elicitation)> {
+        self.elicitations
+            .iter()
+            .enumerate()
+            .rev()
+            .find_map(|(index, elicitation)| {
+                (&elicitation.id == id).then_some((index, elicitation))
+            })
+    }
+
+    fn entry_id_for_url_elicitation(
+        &self,
+        elicitation_id: &acp::ElicitationId,
+    ) -> Option {
+        self.elicitations.iter().rev().find_map(|elicitation| {
+            if let acp::ElicitationMode::Url(mode) = &elicitation.request.mode
+                && &mode.elicitation_id == elicitation_id
+            {
+                Some(elicitation.id.clone())
+            } else {
+                None
+            }
+        })
+    }
+
+    fn elicitation_mut(&mut self, id: &ElicitationEntryId) -> Option<(usize, &mut Elicitation)> {
+        self.elicitations
+            .iter_mut()
+            .enumerate()
+            .rev()
+            .find_map(|(index, elicitation)| {
+                (&elicitation.id == id).then_some((index, elicitation))
+            })
+    }
+
+    fn cancel_pending(
+        &mut self,
+        mut should_cancel: impl FnMut(&Elicitation) -> bool,
+    ) -> Vec {
+        let mut canceled_ids = Vec::new();
+        for elicitation in &mut self.elicitations {
+            if should_cancel(elicitation) && Self::cancel_elicitation_entry(elicitation, true) {
+                canceled_ids.push(elicitation.id.clone());
+            }
+        }
+        canceled_ids
+    }
+}
+
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct ContextCompactionId(pub Arc);
 
@@ -432,6 +788,7 @@ impl AgentThreadEntry {
             Self::UserMessage(message) => message.indented,
             Self::AssistantMessage(message) => message.indented,
             Self::ToolCall(_) => false,
+            Self::Elicitation(_) => false,
             Self::CompletedPlan(_) => false,
             Self::ContextCompaction(_) => false,
         }
@@ -442,6 +799,7 @@ impl AgentThreadEntry {
             Self::UserMessage(message) => message.to_markdown(cx),
             Self::AssistantMessage(message) => message.to_markdown(cx),
             Self::ToolCall(tool_call) => tool_call.to_markdown(cx),
+            Self::Elicitation(_) => "## Input Requested\n\n".to_string(),
             Self::CompletedPlan(entries) => {
                 let mut md = String::from("## Plan\n\n");
                 for entry in entries {
@@ -971,6 +1329,15 @@ impl Display for ToolCallStatus {
     }
 }
 
+fn elicitation_status_for_response(response: &acp::CreateElicitationResponse) -> ElicitationStatus {
+    match &response.action {
+        acp::ElicitationAction::Accept(_) => ElicitationStatus::Accepted,
+        acp::ElicitationAction::Decline => ElicitationStatus::Declined,
+        acp::ElicitationAction::Cancel => ElicitationStatus::Canceled,
+        _ => ElicitationStatus::Canceled,
+    }
+}
+
 #[derive(Debug, PartialEq, Clone)]
 pub enum ContentBlock {
     Empty,
@@ -1721,6 +2088,7 @@ pub struct AcpThread {
     title: Option,
     provisional_title: Option,
     entries: Vec,
+    elicitations: ElicitationStore,
     plan: Plan,
     project: Entity,
     action_log: Entity,
@@ -1789,6 +2157,8 @@ pub enum AcpThreadEvent {
     EntriesRemoved(Range),
     ToolAuthorizationRequested(acp::ToolCallId),
     ToolAuthorizationReceived(acp::ToolCallId),
+    ElicitationRequested(ElicitationEntryId),
+    ElicitationResponded(ElicitationEntryId),
     Retry(RetryStatus),
     SubagentSpawned(acp::SessionId),
     Stopped(acp::StopReason),
@@ -1932,6 +2302,7 @@ impl AcpThread {
             update_last_checkpoint_if_changed_task: None,
             shared_buffers: Default::default(),
             entries: Default::default(),
+            elicitations: ElicitationStore::default(),
             plan: Default::default(),
             title,
             provisional_title: None,
@@ -2084,7 +2455,17 @@ impl AcpThread {
                     status: ToolCallStatus::WaitingForConfirmation { .. },
                     ..
                 }) => return true,
+                AgentThreadEntry::Elicitation(elicitation_id)
+                    if self.elicitations.elicitation(elicitation_id).is_some_and(
+                        |(_, elicitation)| {
+                            matches!(elicitation.status, ElicitationStatus::Pending { .. })
+                        },
+                    ) =>
+                {
+                    return true;
+                }
                 AgentThreadEntry::ToolCall(_)
+                | AgentThreadEntry::Elicitation(_)
                 | AgentThreadEntry::AssistantMessage(_)
                 | AgentThreadEntry::CompletedPlan(_)
                 | AgentThreadEntry::ContextCompaction(_) => {}
@@ -2114,6 +2495,7 @@ impl AcpThread {
                     return true;
                 }
                 AgentThreadEntry::ToolCall(_)
+                | AgentThreadEntry::Elicitation(_)
                 | AgentThreadEntry::AssistantMessage(_)
                 | AgentThreadEntry::CompletedPlan(_)
                 | AgentThreadEntry::ContextCompaction(_) => {}
@@ -2134,6 +2516,7 @@ impl AcpThread {
                     return true;
                 }
                 AgentThreadEntry::ToolCall(_)
+                | AgentThreadEntry::Elicitation(_)
                 | AgentThreadEntry::AssistantMessage(_)
                 | AgentThreadEntry::CompletedPlan(_)
                 | AgentThreadEntry::ContextCompaction(_) => {}
@@ -2149,7 +2532,8 @@ impl AcpThread {
                 AgentThreadEntry::UserMessage(..) => return false,
                 AgentThreadEntry::AssistantMessage(..)
                 | AgentThreadEntry::CompletedPlan(..)
-                | AgentThreadEntry::ContextCompaction(_) => continue,
+                | AgentThreadEntry::ContextCompaction(_)
+                | AgentThreadEntry::Elicitation(_) => continue,
                 AgentThreadEntry::ToolCall(..) => return true,
             }
         }
@@ -3089,6 +3473,99 @@ impl AcpThread {
         cx.emit(AcpThreadEvent::EntryUpdated(ix));
     }
 
+    pub fn request_elicitation(
+        &mut self,
+        request: acp::CreateElicitationRequest,
+        cx: &mut Context,
+    ) -> Result, acp::Error> {
+        self.request_elicitation_with_id(request, cx)
+            .map(|(_, task)| task)
+    }
+
+    pub fn request_elicitation_with_id(
+        &mut self,
+        request: acp::CreateElicitationRequest,
+        cx: &mut Context,
+    ) -> Result<(ElicitationEntryId, Task), acp::Error> {
+        ElicitationStore::validate_request(&request, cx)?;
+
+        let (id, response_rx) = self.elicitations.insert_pending_elicitation(request);
+        self.push_entry(AgentThreadEntry::Elicitation(id.clone()), cx);
+        cx.emit(AcpThreadEvent::ElicitationRequested(id.clone()));
+
+        let task =
+            ElicitationStore::response_task(id.clone(), response_rx, cx, |_thread, cx, id| {
+                cx.emit(AcpThreadEvent::ElicitationResponded(id))
+            });
+
+        Ok((id, task))
+    }
+
+    pub fn respond_to_elicitation(
+        &mut self,
+        id: &ElicitationEntryId,
+        response: acp::CreateElicitationResponse,
+        cx: &mut Context,
+    ) {
+        let Some(ix) = self.elicitation_entry_ix(id) else {
+            return;
+        };
+        if !self.elicitations.respond_to_elicitation_by_id(id, response) {
+            return;
+        }
+
+        cx.emit(AcpThreadEvent::EntryUpdated(ix));
+    }
+
+    pub fn complete_url_elicitation(
+        &mut self,
+        elicitation_id: &acp::ElicitationId,
+        cx: &mut Context,
+    ) {
+        let Some(entry_id) = self
+            .elicitations
+            .entry_id_for_url_elicitation(elicitation_id)
+        else {
+            return;
+        };
+        let Some(ix) = self.elicitation_entry_ix(&entry_id) else {
+            return;
+        };
+        if !self.elicitations.complete_url_elicitation_by_id(&entry_id) {
+            return;
+        }
+
+        cx.emit(AcpThreadEvent::EntryUpdated(ix));
+    }
+
+    pub fn cancel_elicitation(&mut self, id: &ElicitationEntryId, cx: &mut Context) {
+        let Some(ix) = self.elicitation_entry_ix(id) else {
+            return;
+        };
+        if !self.elicitations.cancel_elicitation_by_id(id, true) {
+            return;
+        }
+
+        cx.emit(AcpThreadEvent::EntryUpdated(ix));
+    }
+
+    fn elicitation_entry_ix(&self, id: &ElicitationEntryId) -> Option {
+        self.entries
+            .iter()
+            .enumerate()
+            .rev()
+            .find_map(|(index, entry)| {
+                matches!(entry, AgentThreadEntry::Elicitation(elicitation_id) if elicitation_id == id)
+                    .then_some(index)
+            })
+    }
+
+    pub fn elicitation(&self, id: &ElicitationEntryId) -> Option<(usize, &Elicitation)> {
+        let index = self.elicitation_entry_ix(id)?;
+        let (_, elicitation) = self.elicitations.elicitation(id)?;
+        Some((index, elicitation))
+    }
+
     pub fn plan(&self) -> &Plan {
         &self.plan
     }
@@ -3333,14 +3810,14 @@ impl AcpThread {
                                 log::error!("Max tokens reached. Usage: {:?}", this.token_usage);
                             }
                             if is_same_turn {
-                                this.mark_pending_entries_as_canceled(cx);
+                                this.cancel_pending_turn_entries(cx);
                             }
                             return Err(anyhow!(MaxOutputTokensError));
                         }
 
                         let canceled = matches!(r.stop_reason, acp::StopReason::Cancelled);
                         if canceled && is_same_turn {
-                            this.mark_pending_entries_as_canceled(cx);
+                            this.cancel_pending_turn_entries(cx);
                         }
 
                         if !canceled {
@@ -3404,7 +3881,7 @@ impl AcpThread {
                         }
                         Self::flush_streaming_text(&mut this.streaming_text_buffer, cx);
                         if is_same_turn {
-                            this.mark_pending_entries_as_canceled(cx);
+                            this.cancel_pending_turn_entries(cx);
                         }
                         this.had_error = true;
                         cx.emit(AcpThreadEvent::Error);
@@ -3418,19 +3895,25 @@ impl AcpThread {
     }
 
     pub fn cancel(&mut self, cx: &mut Context) -> Task<()> {
+        Self::flush_streaming_text(&mut self.streaming_text_buffer, cx);
+        self.cancel_outstanding_elicitations(cx);
+
         let Some(turn) = self.running_turn.take() else {
             return Task::ready(());
         };
-        self.connection.cancel(&self.session_id, cx);
-
-        Self::flush_streaming_text(&mut self.streaming_text_buffer, cx);
         self.mark_pending_entries_as_canceled(cx);
+        self.connection.cancel(&self.session_id, cx);
         cx.emit(AcpThreadEvent::StatusChanged);
 
         // Wait for the send task to complete
         cx.background_spawn(turn.send_task)
     }
 
+    fn cancel_pending_turn_entries(&mut self, cx: &mut Context) {
+        self.mark_pending_entries_as_canceled(cx);
+        self.cancel_outstanding_elicitations(cx);
+    }
+
     fn mark_pending_entries_as_canceled(&mut self, cx: &mut Context) {
         for (ix, entry) in self.entries.iter_mut().enumerate() {
             match entry {
@@ -3457,6 +3940,20 @@ impl AcpThread {
         }
     }
 
+    fn cancel_outstanding_elicitations(&mut self, cx: &mut Context) {
+        for ix in 0..self.entries.len() {
+            let Some(AgentThreadEntry::Elicitation(elicitation_id)) = self.entries.get(ix) else {
+                continue;
+            };
+            if self
+                .elicitations
+                .cancel_elicitation_by_id(elicitation_id, true)
+            {
+                cx.emit(AcpThreadEvent::EntryUpdated(ix));
+            }
+        }
+    }
+
     /// Restores the git working tree to the state at the given checkpoint (if one exists)
     pub fn restore_checkpoint(
         &mut self,
@@ -4051,7 +4548,19 @@ impl AcpThread {
     }
 
     pub fn to_markdown(&self, cx: &App) -> String {
-        self.entries.iter().map(|e| e.to_markdown(cx)).collect()
+        self.entries
+            .iter()
+            .map(|entry| match entry {
+                AgentThreadEntry::Elicitation(elicitation_id) => self
+                    .elicitations
+                    .elicitation(elicitation_id)
+                    .map(|(_, elicitation)| {
+                        format!("## Input Requested\n\n{}\n\n", elicitation.request.message)
+                    })
+                    .unwrap_or_else(|| entry.to_markdown(cx)),
+                _ => entry.to_markdown(cx),
+            })
+            .collect()
     }
 
     pub fn emit_load_error(&mut self, error: LoadError, cx: &mut Context) {
@@ -4225,8 +4734,10 @@ fn markdown_for_raw_output(
 mod tests {
     use super::*;
     use anyhow::anyhow;
+    use feature_flags::FeatureFlag as _;
     use futures::stream::StreamExt as _;
     use futures::{channel::mpsc, future::LocalBoxFuture, select};
+    use gpui::UpdateGlobal as _;
     use gpui::{App, AsyncApp, TestAppContext, WeakEntity};
     use indoc::indoc;
     use project::{AgentId, FakeFs, Fs};
@@ -4284,11 +4795,31 @@ mod tests {
     fn init_test(cx: &mut TestAppContext) {
         env_logger::try_init().ok();
         cx.update(|cx| {
-            let settings_store = SettingsStore::test(cx);
+            let mut settings_store = SettingsStore::test(cx);
+            settings_store.register_setting::();
             cx.set_global(settings_store);
         });
     }
 
+    fn enable_acp_beta(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]);
+        });
+    }
+
+    fn set_acp_beta_override(value: &str, cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings(cx, |content| {
+                    content
+                        .feature_flags
+                        .get_or_insert_default()
+                        .insert(AcpBetaFeatureFlag::NAME.to_string(), value.to_string());
+                });
+            });
+        });
+    }
+
     #[test]
     fn text_resource_markdown_uses_mime_type_for_code_blocks() {
         let shell = acp::TextResourceContents::new("echo 'hello from exec test'", "tool://preview")
@@ -6769,6 +7300,1216 @@ mod tests {
         });
     }
 
+    async fn new_test_thread(cx: &mut TestAppContext) -> Entity {
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, [], cx).await;
+        let connection = Rc::new(FakeAgentConnection::new());
+        cx.update(|cx| {
+            connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
+        })
+        .await
+        .unwrap()
+    }
+
+    fn only_thread_elicitation(thread: &AcpThread) -> (ElicitationEntryId, &Elicitation) {
+        let [entry] = thread.entries() else {
+            panic!("expected one elicitation entry, got {:?}", thread.entries());
+        };
+        let AgentThreadEntry::Elicitation(id) = entry else {
+            panic!("expected one elicitation entry, got {:?}", thread.entries());
+        };
+        let Some((_, elicitation)) = thread.elicitation(id) else {
+            panic!("missing elicitation entry");
+        };
+        (id.clone(), elicitation)
+    }
+
+    fn latest_thread_elicitation(thread: &AcpThread) -> (ElicitationEntryId, &Elicitation) {
+        let Some(AgentThreadEntry::Elicitation(id)) = thread.entries().last() else {
+            panic!("expected latest entry to be an elicitation");
+        };
+        let Some((_, elicitation)) = thread.elicitation(id) else {
+            panic!("missing elicitation entry");
+        };
+        (id.clone(), elicitation)
+    }
+
+    #[gpui::test]
+    async fn test_elicitation_requires_acp_beta_flag(cx: &mut TestAppContext) {
+        init_test(cx);
+        cx.update(|cx| {
+            cx.update_flags(false, vec![]);
+        });
+        set_acp_beta_override("off", cx);
+        let thread = new_test_thread(cx).await;
+        let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
+
+        let result = thread.update(cx, |thread, cx| {
+            thread.request_elicitation(
+                acp::CreateElicitationRequest::new(
+                    acp::ElicitationFormMode::new(
+                        acp::ElicitationSessionScope::new(session_id),
+                        acp::ElicitationSchema::new().string("name", true),
+                    ),
+                    "Provide a name",
+                ),
+                cx,
+            )
+        });
+
+        assert!(result.is_err());
+        thread.read_with(cx, |thread, _| assert!(thread.entries().is_empty()));
+    }
+
+    #[gpui::test]
+    async fn test_form_elicitation_accepts_response(cx: &mut TestAppContext) {
+        init_test(cx);
+        enable_acp_beta(cx);
+        let thread = new_test_thread(cx).await;
+        let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
+        let tool_call_id = acp::ToolCallId::new("tool-1");
+
+        let response_task = thread.update(cx, |thread, cx| {
+            thread
+                .request_elicitation(
+                    acp::CreateElicitationRequest::new(
+                        acp::ElicitationFormMode::new(
+                            acp::ElicitationSessionScope::new(session_id.clone())
+                                .tool_call_id(tool_call_id.clone()),
+                            acp::ElicitationSchema::new().string("name", true),
+                        ),
+                        "Provide a name",
+                    ),
+                    cx,
+                )
+                .unwrap()
+        });
+
+        let elicitation_id = thread.read_with(cx, |thread, _| {
+            let (elicitation_id, elicitation) = only_thread_elicitation(thread);
+            let acp::ElicitationScope::Session(scope) = elicitation.request.scope() else {
+                panic!("expected session-scoped elicitation");
+            };
+            assert_eq!(scope.tool_call_id.as_ref(), Some(&tool_call_id));
+            elicitation_id
+        });
+
+        let expected_content = std::collections::BTreeMap::from([(
+            "name".to_string(),
+            acp::ElicitationContentValue::from("Ada"),
+        )]);
+        thread.update(cx, |thread, cx| {
+            thread.respond_to_elicitation(
+                &elicitation_id,
+                acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
+                    acp::ElicitationAcceptAction::new().content(expected_content.clone()),
+                )),
+                cx,
+            );
+        });
+
+        let response = response_task.await;
+        assert_eq!(
+            response.action,
+            acp::ElicitationAction::Accept(
+                acp::ElicitationAcceptAction::new().content(expected_content)
+            )
+        );
+        thread.read_with(cx, |thread, _| {
+            let Some((_, elicitation)) = thread.elicitation(&elicitation_id) else {
+                panic!("missing elicitation entry");
+            };
+            assert!(matches!(elicitation.status, ElicitationStatus::Accepted));
+        });
+    }
+
+    #[gpui::test]
+    async fn test_url_elicitation_can_be_completed(cx: &mut TestAppContext) {
+        init_test(cx);
+        enable_acp_beta(cx);
+        let thread = new_test_thread(cx).await;
+        let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
+        let url_elicitation_id = acp::ElicitationId::new("url-1");
+
+        let response_task = thread.update(cx, |thread, cx| {
+            thread
+                .request_elicitation(
+                    acp::CreateElicitationRequest::new(
+                        acp::ElicitationUrlMode::new(
+                            acp::ElicitationSessionScope::new(session_id),
+                            url_elicitation_id.clone(),
+                            "https://example.com/complete",
+                        ),
+                        "Complete this in the browser",
+                    ),
+                    cx,
+                )
+                .unwrap()
+        });
+
+        let entry_id = thread.read_with(cx, |thread, _| {
+            let (entry_id, _) = only_thread_elicitation(thread);
+            entry_id
+        });
+
+        thread.update(cx, |thread, cx| {
+            thread.complete_url_elicitation(&url_elicitation_id, cx);
+        });
+        assert!(matches!(
+            response_task.await.action,
+            acp::ElicitationAction::Accept(_)
+        ));
+        thread.update(cx, |thread, cx| {
+            thread.respond_to_elicitation(
+                &entry_id,
+                acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline),
+                cx,
+            );
+        });
+        thread.read_with(cx, |thread, _| {
+            let Some((_, elicitation)) = thread.elicitation(&entry_id) else {
+                panic!("missing elicitation entry");
+            };
+            assert!(matches!(elicitation.status, ElicitationStatus::Completed));
+        });
+    }
+
+    #[gpui::test]
+    async fn test_idle_cancel_cancels_accepted_url_elicitation(cx: &mut TestAppContext) {
+        init_test(cx);
+        enable_acp_beta(cx);
+        let thread = new_test_thread(cx).await;
+        let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
+        let url_elicitation_id = acp::ElicitationId::new("url-1");
+
+        let response_task = thread.update(cx, |thread, cx| {
+            thread
+                .request_elicitation(
+                    acp::CreateElicitationRequest::new(
+                        acp::ElicitationUrlMode::new(
+                            acp::ElicitationSessionScope::new(session_id),
+                            url_elicitation_id.clone(),
+                            "https://example.com/complete",
+                        ),
+                        "Complete this in the browser",
+                    ),
+                    cx,
+                )
+                .unwrap()
+        });
+
+        let entry_id = thread.read_with(cx, |thread, _| {
+            let (entry_id, _) = only_thread_elicitation(thread);
+            entry_id
+        });
+
+        thread.update(cx, |thread, cx| {
+            thread.respond_to_elicitation(
+                &entry_id,
+                acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
+                    acp::ElicitationAcceptAction::new(),
+                )),
+                cx,
+            );
+        });
+        assert!(matches!(
+            response_task.await.action,
+            acp::ElicitationAction::Accept(_)
+        ));
+
+        thread.update(cx, |thread, cx| {
+            thread.cancel(cx).detach();
+        });
+        thread.read_with(cx, |thread, _| {
+            let Some((_, elicitation)) = thread.elicitation(&entry_id) else {
+                panic!("missing elicitation entry");
+            };
+            assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
+        });
+
+        thread.update(cx, |thread, cx| {
+            thread.complete_url_elicitation(&url_elicitation_id, cx);
+        });
+        thread.read_with(cx, |thread, _| {
+            let Some((_, elicitation)) = thread.elicitation(&entry_id) else {
+                panic!("missing elicitation entry");
+            };
+            assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
+        });
+    }
+
+    #[gpui::test]
+    async fn test_cancel_accepted_url_elicitation_marks_canceled(cx: &mut TestAppContext) {
+        init_test(cx);
+        enable_acp_beta(cx);
+        let thread = new_test_thread(cx).await;
+        let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
+        let url_elicitation_id = acp::ElicitationId::new("url-1");
+
+        let response_task = thread.update(cx, |thread, cx| {
+            thread
+                .request_elicitation(
+                    acp::CreateElicitationRequest::new(
+                        acp::ElicitationUrlMode::new(
+                            acp::ElicitationSessionScope::new(session_id),
+                            url_elicitation_id.clone(),
+                            "https://example.com/complete",
+                        ),
+                        "Complete this in the browser",
+                    ),
+                    cx,
+                )
+                .unwrap()
+        });
+
+        let entry_id = thread.read_with(cx, |thread, _| {
+            let (entry_id, _) = only_thread_elicitation(thread);
+            entry_id
+        });
+
+        thread.update(cx, |thread, cx| {
+            thread.respond_to_elicitation(
+                &entry_id,
+                acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
+                    acp::ElicitationAcceptAction::new(),
+                )),
+                cx,
+            );
+        });
+        assert!(matches!(
+            response_task.await.action,
+            acp::ElicitationAction::Accept(_)
+        ));
+        thread.read_with(cx, |thread, _| {
+            let Some((_, elicitation)) = thread.elicitation(&entry_id) else {
+                panic!("missing elicitation entry");
+            };
+            assert!(matches!(elicitation.status, ElicitationStatus::Accepted));
+        });
+
+        thread.update(cx, |thread, cx| {
+            thread.cancel(cx).detach();
+        });
+        thread.read_with(cx, |thread, _| {
+            let Some((_, elicitation)) = thread.elicitation(&entry_id) else {
+                panic!("missing elicitation entry");
+            };
+            assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
+        });
+
+        thread.update(cx, |thread, cx| {
+            thread.complete_url_elicitation(&url_elicitation_id, cx);
+        });
+        thread.read_with(cx, |thread, _| {
+            let Some((_, elicitation)) = thread.elicitation(&entry_id) else {
+                panic!("missing elicitation entry");
+            };
+            assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
+        });
+    }
+
+    #[gpui::test]
+    async fn test_turn_cancel_cancels_accepted_url_elicitation_from_previous_turn(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+        enable_acp_beta(cx);
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, [], cx).await;
+        let prompt_count = Rc::new(RefCell::new(0usize));
+        let connection = Rc::new(FakeAgentConnection::new().on_user_message({
+            let prompt_count = prompt_count.clone();
+            move |_request, _thread, _cx| {
+                let stop_reason = {
+                    let mut prompt_count = prompt_count.borrow_mut();
+                    let stop_reason = if *prompt_count == 0 {
+                        acp::StopReason::EndTurn
+                    } else {
+                        acp::StopReason::Cancelled
+                    };
+                    *prompt_count += 1;
+                    stop_reason
+                };
+
+                async move { Ok(acp::PromptResponse::new(stop_reason)) }.boxed_local()
+            }
+        }));
+        let thread = cx
+            .update(|cx| {
+                connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
+            })
+            .await
+            .expect("new session should succeed");
+
+        let response = thread
+            .update(cx, |thread, cx| thread.send(vec!["first turn".into()], cx))
+            .await
+            .expect("first turn should succeed")
+            .expect("first turn should return a response");
+        assert_eq!(response.stop_reason, acp::StopReason::EndTurn);
+
+        let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
+        let url_elicitation_id = acp::ElicitationId::new("url-1");
+        let response_task = thread.update(cx, |thread, cx| {
+            thread
+                .request_elicitation(
+                    acp::CreateElicitationRequest::new(
+                        acp::ElicitationUrlMode::new(
+                            acp::ElicitationSessionScope::new(session_id),
+                            url_elicitation_id.clone(),
+                            "https://example.com/complete",
+                        ),
+                        "Complete this in the browser",
+                    ),
+                    cx,
+                )
+                .expect("url elicitation should be accepted")
+        });
+
+        let entry_id = thread.read_with(cx, |thread, _| {
+            let (entry_id, _) = latest_thread_elicitation(thread);
+            entry_id
+        });
+
+        thread.update(cx, |thread, cx| {
+            thread.respond_to_elicitation(
+                &entry_id,
+                acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
+                    acp::ElicitationAcceptAction::new(),
+                )),
+                cx,
+            );
+        });
+        assert!(matches!(
+            response_task.await.action,
+            acp::ElicitationAction::Accept(_)
+        ));
+
+        let response = thread
+            .update(cx, |thread, cx| thread.send(vec!["second turn".into()], cx))
+            .await
+            .expect("second turn should succeed")
+            .expect("second turn should return a response");
+        assert_eq!(response.stop_reason, acp::StopReason::Cancelled);
+        thread.read_with(cx, |thread, _| {
+            let Some((_, elicitation)) = thread.elicitation(&entry_id) else {
+                panic!("missing elicitation entry");
+            };
+            assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
+        });
+
+        thread.update(cx, |thread, cx| {
+            thread.complete_url_elicitation(&url_elicitation_id, cx);
+        });
+        thread.read_with(cx, |thread, _| {
+            let Some((_, elicitation)) = thread.elicitation(&entry_id) else {
+                panic!("missing elicitation entry");
+            };
+            assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
+        });
+    }
+
+    #[gpui::test]
+    async fn test_request_scoped_elicitation_store_accepts_response(cx: &mut TestAppContext) {
+        init_test(cx);
+        enable_acp_beta(cx);
+        let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
+
+        let response_task = store.update(cx, |store, cx| {
+            store
+                .request_elicitation(
+                    acp::CreateElicitationRequest::new(
+                        acp::ElicitationFormMode::new(
+                            acp::ElicitationRequestScope::new(acp::RequestId::Number(1)),
+                            acp::ElicitationSchema::new().string("name", true),
+                        ),
+                        "Provide a name",
+                    ),
+                    cx,
+                )
+                .unwrap()
+        });
+
+        let elicitation_id = store.read_with(cx, |store, _| {
+            let [elicitation] = store.elicitations() else {
+                panic!(
+                    "expected one elicitation entry, got {:?}",
+                    store.elicitations()
+                );
+            };
+            let acp::ElicitationScope::Request(scope) = elicitation.request.scope() else {
+                panic!("expected request-scoped elicitation");
+            };
+            assert_eq!(scope.request_id, acp::RequestId::Number(1));
+            elicitation.id.clone()
+        });
+
+        store.update(cx, |store, cx| {
+            store.respond_to_elicitation(
+                &elicitation_id,
+                acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline),
+                cx,
+            );
+        });
+
+        assert_eq!(response_task.await.action, acp::ElicitationAction::Decline);
+        store.read_with(cx, |store, _| {
+            let Some((_, elicitation)) = store.elicitation(&elicitation_id) else {
+                panic!("missing elicitation entry");
+            };
+            assert!(matches!(elicitation.status, ElicitationStatus::Declined));
+        });
+    }
+
+    #[gpui::test]
+    async fn test_request_elicitation_store_ignores_duplicate_response(cx: &mut TestAppContext) {
+        init_test(cx);
+        enable_acp_beta(cx);
+        let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
+
+        let response_task = store.update(cx, |store, cx| {
+            store
+                .request_elicitation(
+                    acp::CreateElicitationRequest::new(
+                        acp::ElicitationFormMode::new(
+                            acp::ElicitationRequestScope::new(acp::RequestId::Number(1)),
+                            acp::ElicitationSchema::new().string("name", true),
+                        ),
+                        "Provide a name",
+                    ),
+                    cx,
+                )
+                .unwrap()
+        });
+
+        let elicitation_id = store.read_with(cx, |store, _| {
+            let [elicitation] = store.elicitations() else {
+                panic!(
+                    "expected one elicitation entry, got {:?}",
+                    store.elicitations()
+                );
+            };
+            elicitation.id.clone()
+        });
+
+        store.update(cx, |store, cx| {
+            store.respond_to_elicitation(
+                &elicitation_id,
+                acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline),
+                cx,
+            );
+            store.respond_to_elicitation(
+                &elicitation_id,
+                acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
+                    acp::ElicitationAcceptAction::new(),
+                )),
+                cx,
+            );
+        });
+
+        assert_eq!(response_task.await.action, acp::ElicitationAction::Decline);
+        store.read_with(cx, |store, _| {
+            let Some((_, elicitation)) = store.elicitation(&elicitation_id) else {
+                panic!("missing elicitation entry");
+            };
+            assert!(matches!(elicitation.status, ElicitationStatus::Declined));
+        });
+    }
+
+    #[gpui::test]
+    async fn test_cancel_session_elicitation_by_id_resolves_cancel(cx: &mut TestAppContext) {
+        init_test(cx);
+        enable_acp_beta(cx);
+        let thread = new_test_thread(cx).await;
+        let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
+
+        let (elicitation_id, response_task) = thread.update(cx, |thread, cx| {
+            thread
+                .request_elicitation_with_id(
+                    acp::CreateElicitationRequest::new(
+                        acp::ElicitationFormMode::new(
+                            acp::ElicitationSessionScope::new(session_id),
+                            acp::ElicitationSchema::new().string("name", true),
+                        ),
+                        "Provide a name",
+                    ),
+                    cx,
+                )
+                .unwrap()
+        });
+
+        thread.update(cx, |thread, cx| {
+            thread.cancel_elicitation(&elicitation_id, cx);
+        });
+
+        assert_eq!(response_task.await.action, acp::ElicitationAction::Cancel);
+        thread.read_with(cx, |thread, _| {
+            let Some((_, elicitation)) = thread.elicitation(&elicitation_id) else {
+                panic!("missing elicitation entry");
+            };
+            assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
+        });
+    }
+
+    #[gpui::test]
+    async fn test_cancel_pending_session_elicitation_resolves_cancel(cx: &mut TestAppContext) {
+        init_test(cx);
+        enable_acp_beta(cx);
+        let thread = new_test_thread(cx).await;
+        let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
+
+        let response_task = thread.update(cx, |thread, cx| {
+            thread
+                .request_elicitation(
+                    acp::CreateElicitationRequest::new(
+                        acp::ElicitationFormMode::new(
+                            acp::ElicitationSessionScope::new(session_id),
+                            acp::ElicitationSchema::new().string("name", true),
+                        ),
+                        "Provide a name",
+                    ),
+                    cx,
+                )
+                .unwrap()
+        });
+
+        let elicitation_id = thread.read_with(cx, |thread, _| {
+            let (elicitation_id, _) = only_thread_elicitation(thread);
+            elicitation_id
+        });
+
+        thread.update(cx, |thread, cx| {
+            thread.cancel(cx).detach();
+        });
+
+        assert_eq!(response_task.await.action, acp::ElicitationAction::Cancel);
+        thread.read_with(cx, |thread, _| {
+            let Some((_, elicitation)) = thread.elicitation(&elicitation_id) else {
+                panic!("missing elicitation entry");
+            };
+            assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
+        });
+    }
+
+    fn request_test_session_elicitation(
+        thread: WeakEntity,
+        session_id: acp::SessionId,
+        cx: &mut AsyncApp,
+    ) -> Result> {
+        thread.update(cx, |thread, cx| {
+            thread
+                .request_elicitation(
+                    acp::CreateElicitationRequest::new(
+                        acp::ElicitationFormMode::new(
+                            acp::ElicitationSessionScope::new(session_id),
+                            acp::ElicitationSchema::new().string("name", true),
+                        ),
+                        "Provide a name",
+                    ),
+                    cx,
+                )
+                .map_err(|error| anyhow!(error))
+        })?
+    }
+
+    #[gpui::test]
+    async fn test_prompt_error_cancels_pending_session_elicitation(cx: &mut TestAppContext) {
+        init_test(cx);
+        enable_acp_beta(cx);
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, [], cx).await;
+        let elicitation_action = Rc::new(RefCell::new(None));
+        let connection = Rc::new(FakeAgentConnection::new().on_user_message({
+            let elicitation_action = elicitation_action.clone();
+            move |request, thread, mut cx| {
+                let elicitation_action = elicitation_action.clone();
+                async move {
+                    let response_task =
+                        request_test_session_elicitation(thread, request.session_id, &mut cx)?;
+                    cx.spawn(async move |_cx| {
+                        let response = response_task.await;
+                        *elicitation_action.borrow_mut() = Some(response.action);
+                    })
+                    .detach();
+
+                    Err(anyhow!("prompt failed"))
+                }
+                .boxed_local()
+            }
+        }));
+        let thread = cx
+            .update(|cx| {
+                connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
+            })
+            .await
+            .expect("new session should succeed");
+
+        let result = thread
+            .update(cx, |thread, cx| thread.send(vec!["hello".into()], cx))
+            .await;
+
+        assert!(result.is_err());
+        cx.run_until_parked();
+        assert_eq!(
+            *elicitation_action.borrow(),
+            Some(acp::ElicitationAction::Cancel)
+        );
+        thread.read_with(cx, |thread, _| {
+            let Some(elicitation) = thread.entries().iter().find_map(|entry| match entry {
+                AgentThreadEntry::Elicitation(id) => {
+                    thread.elicitation(id).map(|(_, elicitation)| elicitation)
+                }
+                _ => None,
+            }) else {
+                panic!("expected an elicitation entry");
+            };
+            assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
+        });
+    }
+
+    #[gpui::test]
+    async fn test_max_tokens_cancels_pending_session_elicitation(cx: &mut TestAppContext) {
+        init_test(cx);
+        enable_acp_beta(cx);
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, [], cx).await;
+        let elicitation_action = Rc::new(RefCell::new(None));
+        let connection = Rc::new(FakeAgentConnection::new().on_user_message({
+            let elicitation_action = elicitation_action.clone();
+            move |request, thread, mut cx| {
+                let elicitation_action = elicitation_action.clone();
+                async move {
+                    let response_task =
+                        request_test_session_elicitation(thread, request.session_id, &mut cx)?;
+                    cx.spawn(async move |_cx| {
+                        let response = response_task.await;
+                        *elicitation_action.borrow_mut() = Some(response.action);
+                    })
+                    .detach();
+
+                    Ok(acp::PromptResponse::new(acp::StopReason::MaxTokens))
+                }
+                .boxed_local()
+            }
+        }));
+        let thread = cx
+            .update(|cx| {
+                connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
+            })
+            .await
+            .expect("new session should succeed");
+
+        let result = thread
+            .update(cx, |thread, cx| thread.send(vec!["hello".into()], cx))
+            .await;
+
+        assert!(result.is_err());
+        cx.run_until_parked();
+        assert_eq!(
+            *elicitation_action.borrow(),
+            Some(acp::ElicitationAction::Cancel)
+        );
+        thread.read_with(cx, |thread, _| {
+            let Some(elicitation) = thread.entries().iter().find_map(|entry| match entry {
+                AgentThreadEntry::Elicitation(id) => {
+                    thread.elicitation(id).map(|(_, elicitation)| elicitation)
+                }
+                _ => None,
+            }) else {
+                panic!("expected an elicitation entry");
+            };
+            assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
+        });
+    }
+
+    #[gpui::test]
+    async fn test_cancel_request_scoped_elicitation_resolves_cancel(cx: &mut TestAppContext) {
+        init_test(cx);
+        enable_acp_beta(cx);
+        let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
+
+        let (elicitation_id, response_task) = store.update(cx, |store, cx| {
+            store
+                .request_elicitation_with_id(
+                    acp::CreateElicitationRequest::new(
+                        acp::ElicitationFormMode::new(
+                            acp::ElicitationRequestScope::new(acp::RequestId::Number(1)),
+                            acp::ElicitationSchema::new().string("name", true),
+                        ),
+                        "Provide a name",
+                    ),
+                    cx,
+                )
+                .unwrap()
+        });
+
+        store.update(cx, |store, cx| {
+            store.cancel_elicitation(&elicitation_id, cx);
+        });
+
+        assert_eq!(response_task.await.action, acp::ElicitationAction::Cancel);
+        store.read_with(cx, |store, _| {
+            let Some((_, elicitation)) = store.elicitation(&elicitation_id) else {
+                panic!("missing elicitation entry");
+            };
+            assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
+        });
+    }
+
+    #[gpui::test]
+    async fn test_request_elicitation_store_cancel_all_resolves_cancel(cx: &mut TestAppContext) {
+        init_test(cx);
+        enable_acp_beta(cx);
+        let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
+
+        let response_task = store.update(cx, |store, cx| {
+            store
+                .request_elicitation(
+                    acp::CreateElicitationRequest::new(
+                        acp::ElicitationFormMode::new(
+                            acp::ElicitationRequestScope::new(acp::RequestId::Number(1)),
+                            acp::ElicitationSchema::new().string("name", true),
+                        ),
+                        "Provide a name",
+                    ),
+                    cx,
+                )
+                .unwrap()
+        });
+
+        store.update(cx, |store, cx| {
+            store.cancel_all(cx);
+        });
+
+        assert_eq!(response_task.await.action, acp::ElicitationAction::Cancel);
+    }
+
+    #[gpui::test]
+    async fn test_request_elicitation_store_clear_removes_answered_and_cancels_pending(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+        enable_acp_beta(cx);
+        let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
+
+        let first_response_task = store.update(cx, |store, cx| {
+            store
+                .request_elicitation(
+                    acp::CreateElicitationRequest::new(
+                        acp::ElicitationFormMode::new(
+                            acp::ElicitationRequestScope::new(acp::RequestId::Number(1)),
+                            acp::ElicitationSchema::new().string("name", true),
+                        ),
+                        "Provide a name",
+                    ),
+                    cx,
+                )
+                .unwrap()
+        });
+        let second_response_task = store.update(cx, |store, cx| {
+            store
+                .request_elicitation(
+                    acp::CreateElicitationRequest::new(
+                        acp::ElicitationFormMode::new(
+                            acp::ElicitationRequestScope::new(acp::RequestId::Number(2)),
+                            acp::ElicitationSchema::new().string("account", true),
+                        ),
+                        "Provide an account",
+                    ),
+                    cx,
+                )
+                .unwrap()
+        });
+
+        let first_elicitation_id = store.read_with(cx, |store, _| {
+            let [first, _second] = store.elicitations() else {
+                panic!("expected two elicitations, got {:?}", store.elicitations());
+            };
+            first.id.clone()
+        });
+
+        store.update(cx, |store, cx| {
+            store.respond_to_elicitation(
+                &first_elicitation_id,
+                acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline),
+                cx,
+            );
+            store.clear(cx);
+        });
+
+        assert_eq!(
+            first_response_task.await.action,
+            acp::ElicitationAction::Decline
+        );
+        assert_eq!(
+            second_response_task.await.action,
+            acp::ElicitationAction::Cancel
+        );
+        store.read_with(cx, |store, _| assert!(store.elicitations().is_empty()));
+    }
+
+    #[gpui::test]
+    async fn test_request_elicitation_store_clear_resolved_preserves_outstanding(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+        enable_acp_beta(cx);
+        let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
+        let url_elicitation_id = acp::ElicitationId::new("url-1");
+
+        let accepted_response_task = store.update(cx, |store, cx| {
+            store
+                .request_elicitation(
+                    acp::CreateElicitationRequest::new(
+                        acp::ElicitationFormMode::new(
+                            acp::ElicitationRequestScope::new(acp::RequestId::Number(1)),
+                            acp::ElicitationSchema::new().string("name", true),
+                        ),
+                        "Provide a name",
+                    ),
+                    cx,
+                )
+                .unwrap()
+        });
+        let pending_response_task = store.update(cx, |store, cx| {
+            store
+                .request_elicitation(
+                    acp::CreateElicitationRequest::new(
+                        acp::ElicitationFormMode::new(
+                            acp::ElicitationRequestScope::new(acp::RequestId::Number(2)),
+                            acp::ElicitationSchema::new().string("account", true),
+                        ),
+                        "Provide an account",
+                    ),
+                    cx,
+                )
+                .unwrap()
+        });
+        let accepted_url_response_task = store.update(cx, |store, cx| {
+            store
+                .request_elicitation(
+                    acp::CreateElicitationRequest::new(
+                        acp::ElicitationUrlMode::new(
+                            acp::ElicitationRequestScope::new(acp::RequestId::Number(3)),
+                            url_elicitation_id,
+                            "https://example.com/complete",
+                        ),
+                        "Complete this in the browser",
+                    ),
+                    cx,
+                )
+                .unwrap()
+        });
+
+        let (accepted_id, pending_id, accepted_url_id) = store.read_with(cx, |store, _| {
+            let [accepted, pending, accepted_url] = store.elicitations() else {
+                panic!(
+                    "expected three request-scoped elicitations, got {:?}",
+                    store.elicitations()
+                );
+            };
+            (
+                accepted.id.clone(),
+                pending.id.clone(),
+                accepted_url.id.clone(),
+            )
+        });
+
+        store.update(cx, |store, cx| {
+            store.respond_to_elicitation(
+                &accepted_id,
+                acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
+                    acp::ElicitationAcceptAction::new(),
+                )),
+                cx,
+            );
+            store.respond_to_elicitation(
+                &accepted_url_id,
+                acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
+                    acp::ElicitationAcceptAction::new(),
+                )),
+                cx,
+            );
+        });
+        assert!(matches!(
+            accepted_response_task.await.action,
+            acp::ElicitationAction::Accept(_)
+        ));
+        assert!(matches!(
+            accepted_url_response_task.await.action,
+            acp::ElicitationAction::Accept(_)
+        ));
+
+        let cleared_ids = store.update(cx, |store, cx| store.clear_resolved(cx));
+        assert_eq!(cleared_ids, vec![accepted_id]);
+        store.read_with(cx, |store, _| {
+            let [pending, accepted_url] = store.elicitations() else {
+                panic!(
+                    "expected pending and accepted url elicitations, got {:?}",
+                    store.elicitations()
+                );
+            };
+            assert_eq!(pending.id, pending_id);
+            assert!(matches!(pending.status, ElicitationStatus::Pending { .. }));
+            assert_eq!(accepted_url.id, accepted_url_id);
+            assert!(matches!(accepted_url.status, ElicitationStatus::Accepted));
+        });
+
+        store.update(cx, |store, cx| store.clear(cx));
+        assert_eq!(
+            pending_response_task.await.action,
+            acp::ElicitationAction::Cancel
+        );
+    }
+
+    #[gpui::test]
+    async fn test_request_url_elicitation_store_can_be_completed(cx: &mut TestAppContext) {
+        init_test(cx);
+        enable_acp_beta(cx);
+        let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
+        let url_elicitation_id = acp::ElicitationId::new("url-1");
+
+        let response_task = store.update(cx, |store, cx| {
+            store
+                .request_elicitation(
+                    acp::CreateElicitationRequest::new(
+                        acp::ElicitationUrlMode::new(
+                            acp::ElicitationRequestScope::new(acp::RequestId::Number(1)),
+                            url_elicitation_id.clone(),
+                            "https://example.com/complete",
+                        ),
+                        "Complete this in the browser",
+                    ),
+                    cx,
+                )
+                .unwrap()
+        });
+
+        let entry_id = store.read_with(cx, |store, _| {
+            let [elicitation] = store.elicitations() else {
+                panic!(
+                    "expected one request-scoped elicitation, got {:?}",
+                    store.elicitations()
+                );
+            };
+            elicitation.id.clone()
+        });
+
+        store.update(cx, |store, cx| {
+            store.complete_url_elicitation(&url_elicitation_id, cx);
+        });
+
+        assert!(matches!(
+            response_task.await.action,
+            acp::ElicitationAction::Accept(_)
+        ));
+        store.update(cx, |store, cx| {
+            store.respond_to_elicitation(
+                &entry_id,
+                acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline),
+                cx,
+            );
+        });
+        store.read_with(cx, |store, _| {
+            let Some((_, elicitation)) = store.elicitation(&entry_id) else {
+                panic!("missing elicitation entry");
+            };
+            assert!(matches!(elicitation.status, ElicitationStatus::Completed));
+        });
+    }
+
+    #[gpui::test]
+    async fn test_request_url_elicitation_store_cancel_all_cancels_accepted_url(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+        enable_acp_beta(cx);
+        let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
+        let url_elicitation_id = acp::ElicitationId::new("url-1");
+
+        let response_task = store.update(cx, |store, cx| {
+            store
+                .request_elicitation(
+                    acp::CreateElicitationRequest::new(
+                        acp::ElicitationUrlMode::new(
+                            acp::ElicitationRequestScope::new(acp::RequestId::Number(1)),
+                            url_elicitation_id.clone(),
+                            "https://example.com/complete",
+                        ),
+                        "Complete this in the browser",
+                    ),
+                    cx,
+                )
+                .unwrap()
+        });
+
+        let entry_id = store.read_with(cx, |store, _| {
+            let [elicitation] = store.elicitations() else {
+                panic!(
+                    "expected one elicitation entry, got {:?}",
+                    store.elicitations()
+                );
+            };
+            elicitation.id.clone()
+        });
+
+        store.update(cx, |store, cx| {
+            store.respond_to_elicitation(
+                &entry_id,
+                acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
+                    acp::ElicitationAcceptAction::new(),
+                )),
+                cx,
+            );
+        });
+        assert!(matches!(
+            response_task.await.action,
+            acp::ElicitationAction::Accept(_)
+        ));
+        store.update(cx, |store, cx| {
+            store.cancel_all(cx);
+        });
+        store.read_with(cx, |store, _| {
+            let Some((_, elicitation)) = store.elicitation(&entry_id) else {
+                panic!("missing elicitation entry");
+            };
+            assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
+        });
+
+        store.update(cx, |store, cx| {
+            store.complete_url_elicitation(&url_elicitation_id, cx);
+        });
+        store.read_with(cx, |store, _| {
+            let Some((_, elicitation)) = store.elicitation(&entry_id) else {
+                panic!("missing elicitation entry");
+            };
+            assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
+        });
+    }
+
+    #[gpui::test]
+    async fn test_cancel_pending_elicitations_preserves_responded_statuses(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+        enable_acp_beta(cx);
+        let thread = new_test_thread(cx).await;
+        let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
+
+        let response_task = thread.update(cx, |thread, cx| {
+            thread
+                .request_elicitation(
+                    acp::CreateElicitationRequest::new(
+                        acp::ElicitationFormMode::new(
+                            acp::ElicitationSessionScope::new(session_id),
+                            acp::ElicitationSchema::new().string("name", true),
+                        ),
+                        "Provide a name",
+                    ),
+                    cx,
+                )
+                .unwrap()
+        });
+
+        let elicitation_id = thread.read_with(cx, |thread, _| {
+            let (elicitation_id, _) = only_thread_elicitation(thread);
+            elicitation_id
+        });
+
+        thread.update(cx, |thread, cx| {
+            thread.respond_to_elicitation(
+                &elicitation_id,
+                acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline),
+                cx,
+            );
+            thread.cancel(cx).detach();
+        });
+
+        assert_eq!(response_task.await.action, acp::ElicitationAction::Decline);
+        thread.read_with(cx, |thread, _| {
+            let Some((_, elicitation)) = thread.elicitation(&elicitation_id) else {
+                panic!("missing elicitation entry");
+            };
+            assert!(matches!(elicitation.status, ElicitationStatus::Declined));
+        });
+    }
+
+    #[gpui::test]
+    async fn test_session_elicitation_ignores_duplicate_response(cx: &mut TestAppContext) {
+        init_test(cx);
+        enable_acp_beta(cx);
+        let thread = new_test_thread(cx).await;
+        let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
+
+        let response_task = thread.update(cx, |thread, cx| {
+            thread
+                .request_elicitation(
+                    acp::CreateElicitationRequest::new(
+                        acp::ElicitationFormMode::new(
+                            acp::ElicitationSessionScope::new(session_id),
+                            acp::ElicitationSchema::new().string("name", true),
+                        ),
+                        "Provide a name",
+                    ),
+                    cx,
+                )
+                .unwrap()
+        });
+
+        let elicitation_id = thread.read_with(cx, |thread, _| {
+            let (elicitation_id, _) = only_thread_elicitation(thread);
+            elicitation_id
+        });
+
+        thread.update(cx, |thread, cx| {
+            thread.respond_to_elicitation(
+                &elicitation_id,
+                acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline),
+                cx,
+            );
+            thread.respond_to_elicitation(
+                &elicitation_id,
+                acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
+                    acp::ElicitationAcceptAction::new(),
+                )),
+                cx,
+            );
+        });
+
+        assert_eq!(response_task.await.action, acp::ElicitationAction::Decline);
+        thread.read_with(cx, |thread, _| {
+            let Some((_, elicitation)) = thread.elicitation(&elicitation_id) else {
+                panic!("missing elicitation entry");
+            };
+            assert!(matches!(elicitation.status, ElicitationStatus::Declined));
+        });
+    }
+
+    #[gpui::test]
+    async fn test_url_elicitation_rejects_invalid_url(cx: &mut TestAppContext) {
+        init_test(cx);
+        enable_acp_beta(cx);
+        let thread = new_test_thread(cx).await;
+        let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
+
+        let result = thread.update(cx, |thread, cx| {
+            thread.request_elicitation(
+                acp::CreateElicitationRequest::new(
+                    acp::ElicitationUrlMode::new(
+                        acp::ElicitationSessionScope::new(session_id),
+                        "url-1",
+                        "not a url",
+                    ),
+                    "Complete this in the browser",
+                ),
+                cx,
+            )
+        });
+
+        assert!(result.is_err());
+        thread.read_with(cx, |thread, _| assert!(thread.entries().is_empty()));
+    }
+
     async fn run_until_first_tool_call(
         thread: &Entity,
         cx: &mut TestAppContext,
diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs
index 71c46d3e62272f..eaf51978b5d21a 100644
--- a/crates/acp_thread/src/connection.rs
+++ b/crates/acp_thread/src/connection.rs
@@ -1,4 +1,4 @@
-use crate::AcpThread;
+use crate::{AcpThread, ElicitationStore};
 use agent_client_protocol::schema::v1 as acp;
 use anyhow::Result;
 use chrono::{DateTime, Utc};
@@ -201,6 +201,13 @@ pub trait AgentConnection {
 
     fn cancel(&self, session_id: &acp::SessionId, cx: &mut App);
 
+    /// Request-scoped elicitations are connection-level because they can arrive before a session
+    /// thread exists. Session-scoped elicitations stay in the thread timeline, but use
+    /// `ElicitationStore` for shared processing.
+    fn request_elicitations(&self) -> Option> {
+        None
+    }
+
     fn truncate(
         &self,
         _session_id: &acp::SessionId,
diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs
index 708d1c716874f4..9acc88da7556cf 100644
--- a/crates/agent_servers/src/acp.rs
+++ b/crates/agent_servers/src/acp.rs
@@ -1,6 +1,6 @@
 use acp_thread::{
     AgentConnection, AgentSessionInfo, AgentSessionList, AgentSessionListRequest,
-    AgentSessionListResponse,
+    AgentSessionListResponse, ElicitationStore,
 };
 use action_log::ActionLog;
 use agent_client_protocol::schema::{
@@ -269,6 +269,7 @@ impl FlattenAcpResult for Result, anyhow::Error> {
 struct ClientContext {
     sessions: Rc>>,
     session_list: Rc>>>,
+    request_elicitations: Entity,
 }
 
 fn dispatch_queue_closed_error() -> acp::Error {
@@ -394,6 +395,7 @@ pub struct AcpConnection {
     auth_methods: Vec,
     agent_server_store: WeakEntity,
     agent_capabilities: acp::AgentCapabilities,
+    request_elicitations: Entity,
     defaults: AcpConnectionDefaults,
     child: Option,
     session_list: Option>,
@@ -726,11 +728,19 @@ fn connect_client_future(
             on_request!(handle_wait_for_terminal_exit),
             agent_client_protocol::on_receive_request!(),
         )
+        .on_receive_request(
+            on_request!(handle_create_elicitation),
+            agent_client_protocol::on_receive_request!(),
+        )
         // --- Notification handlers (agent→client) ---
         .on_receive_notification(
             on_notification!(handle_session_notification),
             agent_client_protocol::on_receive_notification!(),
         )
+        .on_receive_notification(
+            on_notification!(handle_complete_elicitation),
+            agent_client_protocol::on_receive_notification!(),
+        )
         .connect_with(
             transport,
             move |connection: ConnectionTo| async move {
@@ -745,7 +755,7 @@ fn connect_client_future(
 
 fn client_capabilities_for_agent(
     agent_id: &AgentId,
-    supports_boolean_config_options: bool,
+    supports_beta_features: bool,
 ) -> acp::ClientCapabilities {
     let mut meta = acp::Meta::from_iter([
         ("terminal_output".into(), true.into()),
@@ -764,13 +774,19 @@ fn client_capabilities_for_agent(
         .auth(acp::AuthCapabilities::new().terminal(true))
         .meta(meta);
 
-    if supports_boolean_config_options {
-        capabilities = capabilities.session(
-            acp::ClientSessionCapabilities::new().config_options(
-                acp::SessionConfigOptionsCapabilities::new()
-                    .boolean(acp::BooleanConfigOptionCapabilities::new()),
-            ),
-        );
+    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
@@ -861,6 +877,7 @@ impl AcpConnection {
 
         let client_session_list: Rc>>> =
             Rc::new(RefCell::new(None));
+        let request_elicitations = cx.new(|_| ElicitationStore::default());
 
         // Set up the foreground dispatch channel for bridging Send handler
         // closures to the !Send foreground thread.
@@ -950,6 +967,7 @@ impl AcpConnection {
         let dispatch_context = ClientContext {
             sessions: sessions.clone(),
             session_list: client_session_list.clone(),
+            request_elicitations: request_elicitations.clone(),
         };
         let dispatch_task = cx.spawn({
             let mut dispatch_rx = dispatch_rx;
@@ -1075,6 +1093,7 @@ impl AcpConnection {
             sessions,
             pending_sessions: Rc::new(RefCell::new(HashMap::default())),
             agent_capabilities: response.agent_capabilities,
+            request_elicitations,
             defaults,
             session_list,
             debug_log,
@@ -1096,6 +1115,7 @@ impl AcpConnection {
         connection: ConnectionTo,
         sessions: Rc>>,
         agent_capabilities: acp::AgentCapabilities,
+        request_elicitations: Entity,
         agent_server_store: WeakEntity,
         io_task: Task<()>,
         dispatch_task: Task<()>,
@@ -1115,6 +1135,7 @@ impl AcpConnection {
             auth_methods: vec![],
             agent_server_store,
             agent_capabilities,
+            request_elicitations,
             defaults,
             child: None,
             session_list: None,
@@ -1993,6 +2014,10 @@ impl AgentConnection for AcpConnection {
         self.connection.send_notification(params).log_err();
     }
 
+    fn request_elicitations(&self) -> Option> {
+        Some(self.request_elicitations.clone())
+    }
+
     fn session_modes(
         &self,
         session_id: &acp::SessionId,
@@ -2073,6 +2098,10 @@ pub mod test_support {
         load_session_count: Arc,
         close_session_count: Arc,
         fail_next_prompt: Arc,
+        auth_elicitation_request: Arc>>,
+        auth_elicitation_response:
+            Arc>>>,
+        auth_elicitation_completion: Arc>>,
         exit_status_sender:
             Arc>>>,
     }
@@ -2105,6 +2134,23 @@ pub mod test_support {
         pub fn fail_next_prompt(&self) {
             self.fail_next_prompt.store(true, Ordering::SeqCst);
         }
+
+        pub fn request_elicitation_during_auth(
+            &self,
+            request: acp::CreateElicitationRequest,
+        ) -> async_channel::Receiver {
+            let (response_tx, response_rx) = async_channel::bounded(1);
+            *self
+                .auth_elicitation_request
+                .lock()
+                .expect("auth elicitation request lock should not be poisoned") = Some(request);
+            *self
+                .auth_elicitation_response
+                .lock()
+                .expect("auth elicitation response lock should not be poisoned") =
+                Some(response_tx);
+            response_rx
+        }
     }
 
     impl crate::AgentServer for FakeAcpAgentServer {
@@ -2125,6 +2171,9 @@ pub mod test_support {
             let load_session_count = self.load_session_count.clone();
             let close_session_count = self.close_session_count.clone();
             let fail_next_prompt = self.fail_next_prompt.clone();
+            let auth_elicitation_request = self.auth_elicitation_request.clone();
+            let auth_elicitation_response = self.auth_elicitation_response.clone();
+            let auth_elicitation_completion = self.auth_elicitation_completion.clone();
             let exit_status_sender = self.exit_status_sender.clone();
             cx.spawn(async move |cx| {
                 let harness = build_fake_acp_connection(
@@ -2132,6 +2181,9 @@ pub mod test_support {
                     load_session_count,
                     close_session_count,
                     fail_next_prompt,
+                    auth_elicitation_request,
+                    auth_elicitation_response,
+                    auth_elicitation_completion,
                     cx,
                 )
                 .await?;
@@ -2303,6 +2355,10 @@ pub mod test_support {
             self.inner.cancel(session_id, cx)
         }
 
+        fn request_elicitations(&self) -> Option> {
+            self.inner.request_elicitations()
+        }
+
         fn truncate(
             &self,
             session_id: &acp::SessionId,
@@ -2353,6 +2409,11 @@ pub mod test_support {
         load_session_count: Arc,
         close_session_count: Arc,
         fail_next_prompt: Arc,
+        auth_elicitation_request: Arc>>,
+        auth_elicitation_response: Arc<
+            Mutex>>,
+        >,
+        auth_elicitation_completion: Arc>>,
         cx: &mut AsyncApp,
     ) -> Result {
         let (client_transport, agent_transport) = agent_client_protocol::Channel::duplex();
@@ -2382,8 +2443,41 @@ pub mod test_support {
                 agent_client_protocol::on_receive_request!(),
             )
             .on_receive_request(
-                async move |_req: acp::AuthenticateRequest, responder, _cx| {
-                    responder.respond(Default::default())
+                {
+                    let auth_elicitation_request = auth_elicitation_request.clone();
+                    let auth_elicitation_response = auth_elicitation_response.clone();
+                    let auth_elicitation_completion = auth_elicitation_completion.clone();
+                    async move |_req: acp::AuthenticateRequest, responder, cx| {
+                        let request = auth_elicitation_request
+                            .lock()
+                            .expect("auth elicitation request lock should not be poisoned")
+                            .take();
+                        let response_tx = auth_elicitation_response
+                            .lock()
+                            .expect("auth elicitation response lock should not be poisoned")
+                            .take();
+                        let completion = auth_elicitation_completion
+                            .lock()
+                            .expect("auth elicitation completion lock should not be poisoned")
+                            .take();
+
+                        if let Some(request) = request {
+                            cx.send_request(request)
+                                .on_receiving_result(async move |result| {
+                                    if let (Ok(response), Some(response_tx)) = (result, response_tx)
+                                    {
+                                        response_tx.send(response).await.ok();
+                                    }
+                                    responder.respond(Default::default())
+                                })?;
+                            if let Some(completion) = completion {
+                                cx.send_notification(completion)?;
+                            }
+                            Ok(())
+                        } else {
+                            responder.respond(Default::default())
+                        }
+                    }
                 },
                 agent_client_protocol::on_receive_request!(),
             )
@@ -2471,9 +2565,11 @@ pub mod test_support {
 
         let agent_capabilities = response.agent_capabilities;
 
+        let request_elicitations = cx.new(|_| ElicitationStore::default());
         let dispatch_context = ClientContext {
             sessions: sessions.clone(),
             session_list: client_session_list.clone(),
+            request_elicitations: request_elicitations.clone(),
         };
         let dispatch_task = cx.spawn({
             let mut dispatch_rx = dispatch_rx;
@@ -2492,6 +2588,7 @@ pub mod test_support {
                 client_conn,
                 sessions,
                 agent_capabilities,
+                request_elicitations,
                 agent_server_store,
                 client_io_task,
                 dispatch_task,
@@ -2527,11 +2624,77 @@ pub mod test_support {
             Arc::new(AtomicUsize::new(0)),
             Arc::new(AtomicUsize::new(0)),
             Arc::new(AtomicBool::new(false)),
+            Arc::new(Mutex::new(None)),
+            Arc::new(Mutex::new(None)),
+            Arc::new(Mutex::new(None)),
             &mut cx.to_async(),
         )
         .await
         .expect("failed to initialize ACP connection")
     }
+
+    #[cfg(test)]
+    pub async fn connect_fake_acp_connection_with_auth_elicitation(
+        project: Entity,
+        request: acp::CreateElicitationRequest,
+        cx: &mut gpui::TestAppContext,
+    ) -> (
+        FakeAcpConnectionHarness,
+        async_channel::Receiver,
+    ) {
+        cx.update(|cx| {
+            let store = settings::SettingsStore::test(cx);
+            cx.set_global(store);
+        });
+
+        let (response_tx, response_rx) = async_channel::bounded(1);
+        let harness = build_fake_acp_connection(
+            project,
+            Arc::new(AtomicUsize::new(0)),
+            Arc::new(AtomicUsize::new(0)),
+            Arc::new(AtomicBool::new(false)),
+            Arc::new(Mutex::new(Some(request))),
+            Arc::new(Mutex::new(Some(response_tx))),
+            Arc::new(Mutex::new(None)),
+            &mut cx.to_async(),
+        )
+        .await
+        .expect("failed to initialize ACP connection");
+
+        (harness, response_rx)
+    }
+
+    #[cfg(test)]
+    pub async fn connect_fake_acp_connection_with_auth_elicitation_completion(
+        project: Entity,
+        request: acp::CreateElicitationRequest,
+        completion: acp::CompleteElicitationNotification,
+        cx: &mut gpui::TestAppContext,
+    ) -> (
+        FakeAcpConnectionHarness,
+        async_channel::Receiver,
+    ) {
+        cx.update(|cx| {
+            let store = settings::SettingsStore::test(cx);
+            cx.set_global(store);
+        });
+
+        let (response_tx, response_rx) = async_channel::bounded(1);
+        let harness = build_fake_acp_connection(
+            project,
+            Arc::new(AtomicUsize::new(0)),
+            Arc::new(AtomicUsize::new(0)),
+            Arc::new(AtomicBool::new(false)),
+            Arc::new(Mutex::new(Some(request))),
+            Arc::new(Mutex::new(Some(response_tx))),
+            Arc::new(Mutex::new(Some(completion))),
+            &mut cx.to_async(),
+        )
+        .await
+        .expect("failed to initialize ACP connection");
+
+        (harness, response_rx)
+    }
 }
 
 #[cfg(test)]
@@ -2540,8 +2703,322 @@ mod tests {
 
     use super::*;
     use feature_flags::FeatureFlag as _;
+    use gpui::UpdateGlobal as _;
     use settings::Settings as _;
 
+    fn init_feature_flags_test(cx: &mut gpui::TestAppContext) {
+        cx.update(|cx| {
+            let mut settings_store = SettingsStore::test(cx);
+            settings_store.register_setting::();
+            cx.set_global(settings_store);
+            cx.update_flags(false, vec![]);
+        });
+    }
+
+    fn set_acp_beta_override(value: &str, cx: &mut gpui::TestAppContext) {
+        cx.update(|cx| {
+            SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings(cx, |content| {
+                    content
+                        .feature_flags
+                        .get_or_insert_default()
+                        .insert(AcpBetaFeatureFlag::NAME.to_string(), value.to_string());
+                });
+            });
+        });
+    }
+
+    #[gpui::test]
+    async fn client_capabilities_omit_elicitation_without_acp_beta(cx: &mut gpui::TestAppContext) {
+        init_feature_flags_test(cx);
+        set_acp_beta_override("off", cx);
+
+        let capabilities = cx.update(|cx| {
+            client_capabilities_for_agent(
+                &AgentId::new("codex-acp"),
+                cx.has_flag::(),
+            )
+        });
+
+        assert!(capabilities.elicitation.is_none());
+    }
+
+    #[gpui::test]
+    async fn client_capabilities_include_elicitation_with_acp_beta(cx: &mut gpui::TestAppContext) {
+        init_feature_flags_test(cx);
+        cx.update(|cx| {
+            cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]);
+        });
+
+        let capabilities = cx.update(|cx| {
+            client_capabilities_for_agent(
+                &AgentId::new("codex-acp"),
+                cx.has_flag::(),
+            )
+        });
+        let elicitation = capabilities
+            .elicitation
+            .expect("elicitation should be advertised when acp-beta is enabled");
+
+        assert!(elicitation.form.is_some());
+        assert!(elicitation.url.is_some());
+    }
+
+    #[gpui::test]
+    async fn request_scoped_elicitation_during_auth_uses_connection_store(
+        cx: &mut gpui::TestAppContext,
+    ) {
+        init_feature_flags_test(cx);
+        cx.update(|cx| {
+            cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]);
+        });
+
+        let fs = fs::FakeFs::new(cx.executor());
+        fs.insert_tree("/", serde_json::json!({ "a": {} })).await;
+        let project = project::Project::test(fs, [std::path::Path::new("/a")], cx).await;
+
+        let request_id = acp::RequestId::Number(1);
+        let (harness, response_rx) =
+            test_support::connect_fake_acp_connection_with_auth_elicitation(
+                project,
+                acp::CreateElicitationRequest::new(
+                    acp::ElicitationFormMode::new(
+                        acp::ElicitationRequestScope::new(request_id.clone()),
+                        acp::ElicitationSchema::new().string("name", true),
+                    ),
+                    "Provide a name",
+                ),
+                cx,
+            )
+            .await;
+        let connection = harness.connection.clone();
+        let auth_task =
+            cx.update(|cx| connection.authenticate(acp::AuthMethodId::new("login"), cx));
+        cx.run_until_parked();
+
+        let store = connection
+            .request_elicitations()
+            .expect("ACP connections expose request-scoped elicitations");
+        let elicitation_id = store.read_with(cx, |store, _| {
+            let [elicitation] = store.elicitations() else {
+                panic!(
+                    "expected one request-scoped elicitation, got {:?}",
+                    store.elicitations()
+                );
+            };
+            let acp::ElicitationScope::Request(scope) = elicitation.request.scope() else {
+                panic!("expected request-scoped elicitation");
+            };
+            assert_eq!(scope.request_id, request_id);
+            elicitation.id.clone()
+        });
+        assert!(
+            connection.sessions.borrow().is_empty(),
+            "auth-time request-scoped elicitations must not require a session"
+        );
+
+        let expected_content = std::collections::BTreeMap::from([(
+            "name".to_string(),
+            acp::ElicitationContentValue::from("Ada"),
+        )]);
+        store.update(cx, |store, cx| {
+            store.respond_to_elicitation(
+                &elicitation_id,
+                acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
+                    acp::ElicitationAcceptAction::new().content(expected_content.clone()),
+                )),
+                cx,
+            );
+        });
+
+        let response = response_rx
+            .recv()
+            .await
+            .expect("fake auth flow should receive elicitation response");
+        assert_eq!(
+            response.action,
+            acp::ElicitationAction::Accept(
+                acp::ElicitationAcceptAction::new().content(expected_content)
+            )
+        );
+        auth_task.await.expect("auth should complete");
+    }
+
+    #[gpui::test]
+    async fn request_scoped_url_elicitation_completion_after_create_is_observed(
+        cx: &mut gpui::TestAppContext,
+    ) {
+        init_feature_flags_test(cx);
+        cx.update(|cx| {
+            cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]);
+        });
+
+        let fs = fs::FakeFs::new(cx.executor());
+        fs.insert_tree("/", serde_json::json!({ "a": {} })).await;
+        let project = project::Project::test(fs, [std::path::Path::new("/a")], cx).await;
+
+        let request_id = acp::RequestId::Number(1);
+        let url_elicitation_id = acp::ElicitationId::new("auth-url");
+        let (harness, response_rx) =
+            test_support::connect_fake_acp_connection_with_auth_elicitation_completion(
+                project,
+                acp::CreateElicitationRequest::new(
+                    acp::ElicitationUrlMode::new(
+                        acp::ElicitationRequestScope::new(request_id.clone()),
+                        url_elicitation_id.clone(),
+                        "https://auth.example.com/device",
+                    ),
+                    "Authorize Zed in your browser",
+                ),
+                acp::CompleteElicitationNotification::new(url_elicitation_id),
+                cx,
+            )
+            .await;
+        let connection = harness.connection.clone();
+        let auth_task =
+            cx.update(|cx| connection.authenticate(acp::AuthMethodId::new("login"), cx));
+        cx.run_until_parked();
+
+        let response = response_rx
+            .recv()
+            .await
+            .expect("fake auth flow should receive elicitation response");
+        assert_eq!(
+            response.action,
+            acp::ElicitationAction::Accept(acp::ElicitationAcceptAction::new())
+        );
+
+        let store = connection
+            .request_elicitations()
+            .expect("ACP connections expose request-scoped elicitations");
+        store.read_with(cx, |store, _| {
+            let [elicitation] = store.elicitations() else {
+                panic!(
+                    "expected one request-scoped elicitation, got {:?}",
+                    store.elicitations()
+                );
+            };
+            let acp::ElicitationScope::Request(scope) = elicitation.request.scope() else {
+                panic!("expected request-scoped elicitation");
+            };
+            assert_eq!(scope.request_id, request_id);
+            assert!(matches!(
+                elicitation.status,
+                acp_thread::ElicitationStatus::Completed
+            ));
+        });
+
+        auth_task.await.expect("auth should complete");
+    }
+
+    #[gpui::test]
+    async fn request_scoped_elicitation_ignores_open_sessions(cx: &mut gpui::TestAppContext) {
+        init_feature_flags_test(cx);
+        cx.update(|cx| {
+            cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]);
+        });
+
+        let fs = fs::FakeFs::new(cx.executor());
+        fs.insert_tree("/", serde_json::json!({ "a": {} })).await;
+        let project = project::Project::test(fs, [std::path::Path::new("/a")], cx).await;
+
+        let request_id = acp::RequestId::Number(1);
+        let (harness, response_rx) =
+            test_support::connect_fake_acp_connection_with_auth_elicitation(
+                project.clone(),
+                acp::CreateElicitationRequest::new(
+                    acp::ElicitationFormMode::new(
+                        acp::ElicitationRequestScope::new(request_id.clone()),
+                        acp::ElicitationSchema::new().string("name", true),
+                    ),
+                    "Provide a name",
+                ),
+                cx,
+            )
+            .await;
+        let connection = harness.connection.clone();
+        let work_dirs = util::path_list::PathList::new(&[std::path::Path::new("/a")]);
+
+        let first_thread = cx
+            .update(|cx| {
+                connection.clone().load_session(
+                    acp::SessionId::new("session-1"),
+                    project.clone(),
+                    work_dirs.clone(),
+                    None,
+                    cx,
+                )
+            })
+            .await
+            .expect("first load_session should succeed");
+        let second_thread = cx
+            .update(|cx| {
+                connection.clone().load_session(
+                    acp::SessionId::new("session-2"),
+                    project,
+                    work_dirs,
+                    None,
+                    cx,
+                )
+            })
+            .await
+            .expect("second load_session should succeed");
+        cx.run_until_parked();
+        assert_eq!(
+            connection.sessions.borrow().len(),
+            2,
+            "test setup should have multiple open sessions"
+        );
+
+        let auth_task =
+            cx.update(|cx| connection.authenticate(acp::AuthMethodId::new("login"), cx));
+        cx.run_until_parked();
+
+        let store = connection
+            .request_elicitations()
+            .expect("ACP connections expose request-scoped elicitations");
+        let elicitation_id = store.read_with(cx, |store, _| {
+            let [elicitation] = store.elicitations() else {
+                panic!(
+                    "expected one request-scoped elicitation, got {:?}",
+                    store.elicitations()
+                );
+            };
+            let acp::ElicitationScope::Request(scope) = elicitation.request.scope() else {
+                panic!("expected request-scoped elicitation");
+            };
+            assert_eq!(scope.request_id, request_id);
+            elicitation.id.clone()
+        });
+
+        for thread in [first_thread, second_thread] {
+            thread.read_with(cx, |thread, _| {
+                assert!(
+                    thread.entries().iter().all(|entry| !matches!(
+                        entry,
+                        acp_thread::AgentThreadEntry::Elicitation(_)
+                    )),
+                    "request-scoped elicitation should not be inserted into a session thread"
+                );
+            });
+        }
+
+        store.update(cx, |store, cx| {
+            store.respond_to_elicitation(
+                &elicitation_id,
+                acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline),
+                cx,
+            );
+        });
+
+        let response = response_rx
+            .recv()
+            .await
+            .expect("fake auth flow should receive elicitation response");
+        assert_eq!(response.action, acp::ElicitationAction::Decline);
+        auth_task.await.expect("auth should complete");
+    }
+
     #[test]
     fn cursor_client_capabilities_include_parameterized_model_picker_meta() {
         let capabilities = client_capabilities_for_agent(&AgentId::new(CURSOR_ID), false);
@@ -3243,10 +3720,12 @@ mod tests {
         let sessions = Rc::new(RefCell::new(HashMap::default()));
 
         let connection = cx.update(|cx| {
+            let request_elicitations = cx.new(|_| ElicitationStore::default());
             AcpConnection::new_for_test(
                 client_conn,
                 sessions,
                 acp::AgentCapabilities::default(),
+                request_elicitations,
                 WeakEntity::new_invalid(),
                 client_io_task,
                 Task::ready(()),
@@ -3519,9 +3998,11 @@ mod tests {
 
         let agent_capabilities = response.agent_capabilities;
 
+        let request_elicitations = cx.new(|_| ElicitationStore::default());
         let dispatch_context = ClientContext {
             sessions: sessions.clone(),
             session_list: client_session_list.clone(),
+            request_elicitations: request_elicitations.clone(),
         };
         // `TestAppContext::spawn` hands out an `AsyncApp` by value, whereas the
         // production path uses `Context::spawn` which hands out `&mut AsyncApp`.
@@ -3545,6 +4026,7 @@ mod tests {
                 client_conn,
                 sessions,
                 agent_capabilities,
+                request_elicitations,
                 agent_server_store,
                 client_io_task,
                 dispatch_task,
@@ -3709,6 +4191,7 @@ mod tests {
                     acp_thread::AgentThreadEntry::UserMessage(_) => "user",
                     acp_thread::AgentThreadEntry::AssistantMessage(_) => "assistant",
                     acp_thread::AgentThreadEntry::ToolCall(_) => "tool_call",
+                    acp_thread::AgentThreadEntry::Elicitation(_) => "elicitation",
                     acp_thread::AgentThreadEntry::CompletedPlan(_) => "plan",
                     acp_thread::AgentThreadEntry::ContextCompaction(_) => "compaction",
                 })
@@ -4164,6 +4647,135 @@ fn handle_request_permission(
     .detach();
 }
 
+fn handle_create_elicitation(
+    args: acp::CreateElicitationRequest,
+    responder: Responder,
+    cx: &mut AsyncApp,
+    ctx: &ClientContext,
+) {
+    if !cx.update(|cx| cx.has_flag::()) {
+        return respond_err(
+            responder,
+            acp::Error::invalid_params().data("elicitation support requires the ACP beta flag"),
+        );
+    }
+
+    match args.scope() {
+        acp::ElicitationScope::Session(scope) => {
+            let thread = match session_thread(ctx, &scope.session_id) {
+                Ok(t) => t,
+                Err(e) => return respond_err(responder, e),
+            };
+
+            let (elicitation_id, task) = match thread
+                .update(cx, |thread, cx| {
+                    thread.request_elicitation_with_id(args, cx)
+                })
+                .flatten_acp()
+            {
+                Ok(task) => task,
+                Err(e) => return respond_err(responder, e),
+            };
+
+            let cancellation = responder.cancellation();
+            cx.spawn(async move |cx| {
+                let result: Result<_, acp::Error> = cancellation
+                    .run_until_cancelled(async { Ok(task.await) })
+                    .await;
+
+                match result {
+                    Ok(response) => {
+                        responder.respond(response).log_err();
+                    }
+                    Err(e) => {
+                        if e.code == ErrorCode::RequestCancelled {
+                            thread
+                                .update(cx, |thread, cx| {
+                                    thread.cancel_elicitation(&elicitation_id, cx)
+                                })
+                                .log_err();
+                        }
+                        respond_err(responder, e);
+                    }
+                }
+            })
+            .detach();
+        }
+        acp::ElicitationScope::Request(_) => {
+            let store = ctx.request_elicitations.clone();
+            let (elicitation_id, task) =
+                match store.update(cx, |store, cx| store.request_elicitation_with_id(args, cx)) {
+                    Ok(task) => task,
+                    Err(e) => return respond_err(responder, e),
+                };
+            let store = store.downgrade();
+
+            let cancellation = responder.cancellation();
+            cx.spawn(async move |cx| {
+                let result: Result<_, acp::Error> = cancellation
+                    .run_until_cancelled(async { Ok(task.await) })
+                    .await;
+
+                match result {
+                    Ok(response) => {
+                        responder.respond(response).log_err();
+                    }
+                    Err(e) => {
+                        if e.code == ErrorCode::RequestCancelled {
+                            store
+                                .update(cx, |store, cx| {
+                                    store.cancel_elicitation(&elicitation_id, cx)
+                                })
+                                .log_err();
+                        }
+                        respond_err(responder, e);
+                    }
+                }
+            })
+            .detach();
+        }
+        _ => {
+            respond_err(
+                responder,
+                acp::Error::invalid_params().data("unknown elicitation scope"),
+            );
+        }
+    }
+}
+
+fn handle_complete_elicitation(
+    args: acp::CompleteElicitationNotification,
+    cx: &mut AsyncApp,
+    ctx: &ClientContext,
+) {
+    if !cx.update(|cx| cx.has_flag::()) {
+        return;
+    }
+
+    let threads = ctx
+        .sessions
+        .borrow()
+        .values()
+        .map(|session| session.thread.clone())
+        .collect::>();
+    let request_elicitations = ctx.request_elicitations.clone();
+    let elicitation_id = args.elicitation_id;
+
+    cx.spawn(async move |cx| {
+        for thread in threads {
+            thread
+                .update(cx, |thread, cx| {
+                    thread.complete_url_elicitation(&elicitation_id, cx);
+                })
+                .ok();
+        }
+        request_elicitations.update(cx, |store, cx| {
+            store.complete_url_elicitation(&elicitation_id, cx);
+        });
+    })
+    .detach();
+}
+
 fn handle_write_text_file(
     args: acp::WriteTextFileRequest,
     responder: Responder,
diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs
index 8a329bdc7c84fa..ba731ec3b9ea23 100644
--- a/crates/agent_ui/src/agent_diff.rs
+++ b/crates/agent_ui/src/agent_diff.rs
@@ -1437,6 +1437,8 @@ impl AgentDiff {
             | AcpThreadEvent::EntriesRemoved(_)
             | AcpThreadEvent::ToolAuthorizationRequested(_)
             | AcpThreadEvent::ToolAuthorizationReceived(_)
+            | AcpThreadEvent::ElicitationRequested(_)
+            | AcpThreadEvent::ElicitationResponded(_)
             | AcpThreadEvent::PromptCapabilitiesUpdated
             | AcpThreadEvent::AvailableCommandsUpdated(_)
             | AcpThreadEvent::Retry(_)
diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs
index 5267e7adfa6eca..350d9c2f912569 100644
--- a/crates/agent_ui/src/conversation_view.rs
+++ b/crates/agent_ui/src/conversation_view.rs
@@ -1,8 +1,9 @@
 use acp_thread::{
     AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk,
-    AuthRequired, ClientUserMessageId, LoadError, MaxOutputTokensError, MentionUri,
-    PermissionOptionChoice, PermissionOptions, PermissionPattern, RetryStatus,
-    SelectedPermissionOutcome, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus,
+    AuthRequired, ClientUserMessageId, ElicitationEntryId, ElicitationStatus, ElicitationStore,
+    LoadError, MaxOutputTokensError, MentionUri, PermissionOptionChoice, PermissionOptions,
+    PermissionPattern, RetryStatus, SelectedPermissionOutcome, ThreadStatus, ToolCall,
+    ToolCallContent, ToolCallStatus,
 };
 use acp_thread::{AgentConnection, Plan};
 use action_log::{ActionLog, ActionLogTelemetry, DiffStats};
@@ -22,6 +23,7 @@ use editor::scroll::Autoscroll;
 use editor::{
     Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, SizingBehavior,
 };
+use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _};
 use file_icons::FileIcons;
 use fs::Fs;
 use futures::FutureExt as _;
@@ -40,6 +42,9 @@ use markdown::{
 use parking_lot::{Mutex, RwLock};
 use project::{AgentId, AgentServerStore, Project, ProjectEntryId, ProjectPath};
 
+use crate::conversation_view::elicitation::{
+    ElicitationCard, ElicitationCardHandlers, ElicitationFormState, should_render_elicitation,
+};
 use crate::message_editor::SessionCapabilities;
 use crate::{AgentThreadSource, DEFAULT_THREAD_TITLE, resolve_agent_image};
 use lru::LruCache;
@@ -103,6 +108,7 @@ const TOKEN_THRESHOLD: u64 = 250;
 
 pub(crate) const DRAFT_PROMPT_PERSIST_DEBOUNCE: Duration = Duration::from_millis(250);
 
+pub(crate) mod elicitation;
 mod message_queue;
 mod thread_search_bar;
 mod thread_view;
@@ -261,6 +267,7 @@ impl ProfileProvider for Entity {
 pub(crate) struct Conversation {
     threads: HashMap>,
     permission_requests: IndexMap>,
+    elicitation_requests: IndexMap>,
     subscriptions: Vec,
     updated_at: Option,
 }
@@ -270,7 +277,7 @@ impl Conversation {
         let session_id = thread.read(cx).session_id().clone();
         let subscription = cx.subscribe(&thread, {
             let session_id = session_id.clone();
-            move |this, _thread, event, _cx| {
+            move |this, _thread, event, cx| {
                 this.updated_at = Some(Instant::now());
                 match event {
                     AcpThreadEvent::ToolAuthorizationRequested(id) => {
@@ -287,6 +294,23 @@ impl Conversation {
                             }
                         }
                     }
+                    AcpThreadEvent::ElicitationRequested(id)
+                        if cx.has_flag::() =>
+                    {
+                        this.elicitation_requests
+                            .entry(session_id.clone())
+                            .or_default()
+                            .push(id.clone());
+                    }
+                    AcpThreadEvent::ElicitationRequested(_) => {}
+                    AcpThreadEvent::ElicitationResponded(id) => {
+                        if let Some(elicitations) = this.elicitation_requests.get_mut(&session_id) {
+                            elicitations.retain(|elicitation_id| elicitation_id != id);
+                            if elicitations.is_empty() {
+                                this.elicitation_requests.shift_remove(&session_id);
+                            }
+                        }
+                    }
                     AcpThreadEvent::NewEntry
                     | AcpThreadEvent::StatusChanged
                     | AcpThreadEvent::TitleUpdated
@@ -391,6 +415,24 @@ impl Conversation {
             .unwrap_or(0)
     }
 
+    pub fn respond_to_elicitation(
+        &mut self,
+        session_id: acp::SessionId,
+        elicitation_id: ElicitationEntryId,
+        response: acp::CreateElicitationResponse,
+        cx: &mut Context,
+    ) -> Option<()> {
+        if !cx.has_flag::() {
+            return None;
+        }
+
+        let thread = self.threads.get(&session_id)?.clone();
+        thread.update(cx, |thread, cx| {
+            thread.respond_to_elicitation(&elicitation_id, response, cx);
+        });
+        Some(())
+    }
+
     pub fn authorize_pending_tool_call(
         &mut self,
         session_id: &acp::SessionId,
@@ -526,6 +568,8 @@ fn affects_thread_metadata(event: &AcpThreadEvent) -> bool {
         | AcpThreadEvent::TitleUpdated
         | AcpThreadEvent::ToolAuthorizationRequested(_)
         | AcpThreadEvent::ToolAuthorizationReceived(_)
+        | AcpThreadEvent::ElicitationRequested(_)
+        | AcpThreadEvent::ElicitationResponded(_)
         | AcpThreadEvent::Stopped(_)
         | AcpThreadEvent::Error
         | AcpThreadEvent::LoadError(_)
@@ -575,6 +619,7 @@ pub struct ConversationView {
     /// Cache + worktree snapshot for resolving paths in markdown code spans.
     /// Shared with the child [`ThreadView`] when one is constructed.
     pub(crate) code_span_resolver: AgentCodeSpanResolver,
+    request_elicitation_form_states: HashMap,
     _subscriptions: Vec,
 }
 
@@ -686,8 +731,14 @@ impl ConversationView {
 }
 
 enum ServerState {
-    Loading { _loading: Entity },
-    LoadError { error: LoadError },
+    Loading {
+        _loading: Entity,
+        connection: Option>,
+        _request_elicitation_subscription: Option,
+    },
+    LoadError {
+        error: LoadError,
+    },
     Connected(ConnectedServerState),
 }
 
@@ -700,6 +751,7 @@ pub struct ConnectedServerState {
     connection: Rc,
     conversation: Entity,
     _connection_entry_subscription: Subscription,
+    _request_elicitation_subscription: Option,
 }
 
 enum AuthState {
@@ -800,6 +852,7 @@ impl ConversationView {
         }));
 
         cx.on_release(|this, cx| {
+            this.request_elicitation_form_states.clear();
             if let Some(connected) = this.as_connected() {
                 connected.close_all_sessions(cx).detach();
             }
@@ -845,16 +898,29 @@ impl ConversationView {
             last_theme_id: Some(cx.theme().id.clone()),
             draft_prompt_persist_task: None,
             code_span_resolver,
+            request_elicitation_form_states: HashMap::default(),
             _subscriptions: subscriptions,
             focus_handle: cx.focus_handle(),
         }
     }
 
     fn set_server_state(&mut self, state: ServerState, cx: &mut Context) {
+        let previous_request_elicitation_connection = self.request_elicitation_connection();
+        let next_request_elicitation_connection =
+            Self::request_elicitation_connection_for_state(&state);
+
         if let Some(connected) = self.as_connected() {
             connected.close_all_sessions(cx).detach();
         }
 
+        if let Some(connection) = previous_request_elicitation_connection
+            && !next_request_elicitation_connection
+                .as_ref()
+                .is_some_and(|next_connection| Rc::ptr_eq(&connection, next_connection))
+        {
+            self.request_elicitation_form_states.clear();
+        }
+
         self.server_state = state;
         cx.emit(StateChange);
         cx.emit(AcpServerViewEvent::ActiveThreadChanged);
@@ -864,6 +930,53 @@ impl ConversationView {
         cx.notify();
     }
 
+    fn request_elicitation_subscription(
+        connection: &Rc,
+        cx: &mut Context,
+    ) -> Option {
+        let store = connection.request_elicitations()?;
+        Some(cx.observe(&store, |this, _store, cx| {
+            if let Some(active_thread) = this.active_thread().cloned() {
+                active_thread.update(cx, |_thread, cx| cx.notify());
+            }
+            cx.notify();
+        }))
+    }
+
+    fn request_elicitation_connection(&self) -> Option> {
+        Self::request_elicitation_connection_for_state(&self.server_state)
+    }
+
+    fn active_thread_renders_request_elicitations(&self) -> bool {
+        match &self.server_state {
+            ServerState::Connected(connected) => {
+                connected.auth_state.is_ok() && connected.active_view().is_some()
+            }
+            _ => false,
+        }
+    }
+
+    fn request_elicitation_connection_for_state(
+        state: &ServerState,
+    ) -> Option> {
+        match state {
+            ServerState::Loading {
+                connection: Some(connection),
+                ..
+            } => Some(connection.clone()),
+            ServerState::Connected(connected) => Some(connected.connection.clone()),
+            ServerState::Loading {
+                connection: None, ..
+            }
+            | ServerState::LoadError { .. } => None,
+        }
+    }
+
+    fn request_elicitation_store(&self) -> Option> {
+        self.request_elicitation_connection()?
+            .request_elicitations()
+    }
+
     fn reset(&mut self, window: &mut Window, cx: &mut Context) {
         let (resume_session_id, work_dirs, title) = self
             .root_thread_view()
@@ -889,6 +1002,7 @@ impl ConversationView {
                 (session_id, work_dirs, title)
             });
 
+        self.clear_resolved_request_elicitations(cx);
         self.loading_status = None;
 
         let state = Self::initial_state(
@@ -978,6 +1092,22 @@ impl ConversationView {
                 }
             };
 
+            this.update_in(cx, |this, _window, cx| {
+                let request_elicitation_subscription =
+                    Self::request_elicitation_subscription(&connection, cx);
+                if let ServerState::Loading {
+                    connection: loading_connection,
+                    _request_elicitation_subscription,
+                    ..
+                } = &mut this.server_state
+                {
+                    *loading_connection = Some(connection.clone());
+                    *_request_elicitation_subscription = request_elicitation_subscription;
+                    cx.notify();
+                }
+            })
+            .log_err();
+
             telemetry::event!(
                 "Agent Thread Started",
                 agent = connection.telemetry_id(),
@@ -1050,6 +1180,7 @@ impl ConversationView {
             this.update_in(cx, |this, window, cx| {
                 match result {
                     Ok(thread) => {
+                        this.clear_resolved_request_elicitations_for_connection(&connection, cx);
                         let root_session_id = thread.read(cx).session_id().clone();
 
                         let conversation = cx.new(|cx| {
@@ -1076,6 +1207,8 @@ impl ConversationView {
                         }
 
                         this.root_session_id = Some(root_session_id.clone());
+                        let request_elicitation_subscription =
+                            Self::request_elicitation_subscription(&connection, cx);
                         this.set_server_state(
                             ServerState::Connected(ConnectedServerState {
                                 connection,
@@ -1084,6 +1217,7 @@ impl ConversationView {
                                 threads: HashMap::from_iter([(root_session_id, current)]),
                                 conversation,
                                 _connection_entry_subscription: connection_entry_subscription,
+                                _request_elicitation_subscription: request_elicitation_subscription,
                             }),
                             cx,
                         );
@@ -1106,6 +1240,8 @@ impl ConversationView {
 
         ServerState::Loading {
             _loading: loading_view,
+            connection: None,
+            _request_elicitation_subscription: None,
         }
     }
 
@@ -1376,6 +1512,8 @@ impl ConversationView {
                     this.focus_handle.focus(window, cx)
                 }
             } else {
+                let request_elicitation_subscription =
+                    Self::request_elicitation_subscription(&connection, cx);
                 this.set_server_state(
                     ServerState::Connected(ConnectedServerState {
                         auth_state,
@@ -1384,6 +1522,7 @@ impl ConversationView {
                         connection,
                         conversation: cx.new(|_cx| Conversation::default()),
                         _connection_entry_subscription: Subscription::new(|| {}),
+                        _request_elicitation_subscription: request_elicitation_subscription,
                     }),
                     cx,
                 );
@@ -1521,6 +1660,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_generating_indicator(cx);
                     });
@@ -1535,6 +1675,7 @@ impl ConversationView {
                     });
                     list_state.remeasure_items(*index..*index + 1);
                     active.update(cx, |active, cx| {
+                        active.sync_elicitation_state_for_entry(*index, window, cx);
                         active.auto_expand_streaming_thought(cx);
                         active.sync_generating_indicator(cx);
                     });
@@ -1558,6 +1699,11 @@ impl ConversationView {
                 self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx);
             }
             AcpThreadEvent::ToolAuthorizationReceived(_) => {}
+            AcpThreadEvent::ElicitationRequested(_) if cx.has_flag::() => {
+                self.notify_with_sound("Waiting for input", IconName::Info, window, cx);
+            }
+            AcpThreadEvent::ElicitationRequested(_) => {}
+            AcpThreadEvent::ElicitationResponded(_) => {}
             AcpThreadEvent::Retry(retry) => {
                 if let Some(active) = self.thread_view(&session_id) {
                     active.update(cx, |active, _cx| {
@@ -1868,6 +2014,7 @@ impl ConversationView {
 
                     this.update_in(cx, |this, window, cx| {
                         if let Err(err) = result {
+                            this.cancel_request_elicitations(cx);
                             if let Some(ConnectedServerState {
                                 auth_state:
                                     AuthState::Unauthenticated {
@@ -1918,6 +2065,7 @@ impl ConversationView {
 
                 this.update_in(cx, |this, window, cx| {
                     if let Err(err) = result {
+                        this.cancel_request_elicitations(cx);
                         if let Some(ConnectedServerState {
                             auth_state:
                                 AuthState::Unauthenticated {
@@ -2256,6 +2404,311 @@ impl ConversationView {
             .into_any_element()
     }
 
+    fn sync_request_elicitation_states(&mut self, window: &mut Window, cx: &mut Context) {
+        if !cx.has_flag::() {
+            self.request_elicitation_form_states.clear();
+            return;
+        }
+
+        let Some(store) = self.request_elicitation_store() else {
+            self.request_elicitation_form_states.clear();
+            return;
+        };
+
+        let elicitations = store
+            .read(cx)
+            .elicitations()
+            .iter()
+            .map(|elicitation| {
+                let is_pending = matches!(elicitation.status, ElicitationStatus::Pending { .. });
+                let schema = match &elicitation.request.mode {
+                    acp::ElicitationMode::Form(mode) => Some(mode.requested_schema.clone()),
+                    _ => None,
+                };
+                (elicitation.id.clone(), is_pending, schema)
+            })
+            .collect::>();
+
+        let known_ids = elicitations
+            .iter()
+            .map(|(id, _, _)| id.clone())
+            .collect::>();
+        self.request_elicitation_form_states
+            .retain(|id, _| known_ids.contains(id));
+
+        for (id, is_pending, schema) in elicitations {
+            if is_pending
+                && let Some(schema) = schema
+                && !self.request_elicitation_form_states.contains_key(&id)
+            {
+                self.request_elicitation_form_states
+                    .insert(id, ElicitationFormState::new(&schema, window, cx));
+            } else if !is_pending {
+                self.request_elicitation_form_states.remove(&id);
+            }
+        }
+    }
+
+    fn render_request_elicitations(
+        &self,
+        connection: &Rc,
+        view: WeakEntity,
+        cx: &App,
+    ) -> Vec {
+        if !cx.has_flag::() {
+            return Vec::new();
+        }
+
+        let Some(store) = connection.request_elicitations() else {
+            return Vec::new();
+        };
+
+        let handlers = Self::request_elicitation_card_handlers(view);
+
+        store
+            .read(cx)
+            .elicitations()
+            .iter()
+            .enumerate()
+            .filter(|(_, elicitation)| should_render_elicitation(elicitation))
+            .map(|(ix, elicitation)| {
+                ElicitationCard::new(
+                    ix,
+                    elicitation,
+                    self.request_elicitation_form_states.get(&elicitation.id),
+                    handlers.clone(),
+                )
+                .render(cx)
+                .into_any_element()
+            })
+            .collect()
+    }
+
+    fn request_elicitation_card_handlers(view: WeakEntity) -> ElicitationCardHandlers {
+        ElicitationCardHandlers::new(
+            {
+                let view = view.clone();
+                move |elicitation_id, window, cx| {
+                    view.update(cx, |this, cx| {
+                        this.submit_request_elicitation(elicitation_id, window, cx);
+                    })
+                    .log_err();
+                }
+            },
+            {
+                let view = view.clone();
+                move |elicitation_id, window, cx| {
+                    view.update(cx, |this, cx| {
+                        this.decline_request_elicitation(elicitation_id, window, cx);
+                    })
+                    .log_err();
+                }
+            },
+            {
+                let view = view.clone();
+                move |elicitation_id, window, cx| {
+                    view.update(cx, |this, cx| {
+                        this.cancel_request_elicitation(elicitation_id, window, cx);
+                    })
+                    .log_err();
+                }
+            },
+            {
+                let view = view.clone();
+                move |elicitation_id, url, window, cx| {
+                    cx.open_url(&url);
+                    view.update(cx, |this, cx| {
+                        this.submit_request_elicitation(elicitation_id, window, cx);
+                    })
+                    .log_err();
+                }
+            },
+            {
+                let view = view.clone();
+                move |elicitation_id, field_name, value, cx| {
+                    view.update(cx, |this, cx| {
+                        this.update_request_elicitation_form_state(
+                            &elicitation_id,
+                            |form| form.set_boolean(&field_name, value),
+                            cx,
+                        );
+                    })
+                    .log_err();
+                }
+            },
+            {
+                let view = view.clone();
+                move |elicitation_id, field_name, value, cx| {
+                    view.update(cx, |this, cx| {
+                        this.update_request_elicitation_form_state(
+                            &elicitation_id,
+                            |form| form.set_single_select(&field_name, value),
+                            cx,
+                        );
+                    })
+                    .log_err();
+                }
+            },
+            move |elicitation_id, field_name, value, selected, cx| {
+                view.update(cx, |this, cx| {
+                    this.update_request_elicitation_form_state(
+                        &elicitation_id,
+                        |form| form.set_multi_select(&field_name, value, selected),
+                        cx,
+                    );
+                })
+                .log_err();
+            },
+        )
+    }
+
+    fn update_request_elicitation_form_state(
+        &mut self,
+        elicitation_id: &ElicitationEntryId,
+        update: impl FnOnce(&mut ElicitationFormState),
+        cx: &mut Context,
+    ) {
+        if let Some(form) = self.request_elicitation_form_states.get_mut(elicitation_id) {
+            update(form);
+            self.notify_request_elicitation_renderers(cx);
+        }
+    }
+
+    fn notify_request_elicitation_renderers(&self, cx: &mut Context) {
+        if let Some(active_thread) = self.active_thread().cloned() {
+            active_thread.update(cx, |_thread, cx| cx.notify());
+        }
+        cx.notify();
+    }
+
+    fn submit_request_elicitation(
+        &mut self,
+        elicitation_id: ElicitationEntryId,
+        _window: &mut Window,
+        cx: &mut Context,
+    ) {
+        if !cx.has_flag::() {
+            return;
+        }
+
+        let Some(store) = self.request_elicitation_store() else {
+            return;
+        };
+
+        let mode = store
+            .read(cx)
+            .elicitation(&elicitation_id)
+            .map(|(_, elicitation)| elicitation.request.mode.clone());
+        let Some(mode) = mode else {
+            return;
+        };
+
+        let response = match mode {
+            acp::ElicitationMode::Form(mode) => {
+                let Some(state) = self.request_elicitation_form_states.get(&elicitation_id) else {
+                    return;
+                };
+                match state.collect(&mode.requested_schema, cx) {
+                    Ok(content) => {
+                        acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
+                            acp::ElicitationAcceptAction::new().content(content),
+                        ))
+                    }
+                    Err(errors) => {
+                        self.update_request_elicitation_form_state(
+                            &elicitation_id,
+                            |state| state.set_errors(errors),
+                            cx,
+                        );
+                        return;
+                    }
+                }
+            }
+            acp::ElicitationMode::Url(_) => acp::CreateElicitationResponse::new(
+                acp::ElicitationAction::Accept(acp::ElicitationAcceptAction::new()),
+            ),
+            _ => return,
+        };
+
+        self.respond_to_request_elicitation(elicitation_id, response, cx);
+    }
+
+    fn decline_request_elicitation(
+        &mut self,
+        elicitation_id: ElicitationEntryId,
+        _window: &mut Window,
+        cx: &mut Context,
+    ) {
+        if !cx.has_flag::() {
+            return;
+        }
+
+        self.respond_to_request_elicitation(
+            elicitation_id,
+            acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline),
+            cx,
+        );
+    }
+
+    fn cancel_request_elicitation(
+        &mut self,
+        elicitation_id: ElicitationEntryId,
+        _window: &mut Window,
+        cx: &mut Context,
+    ) {
+        if !cx.has_flag::() {
+            return;
+        }
+
+        self.respond_to_request_elicitation(
+            elicitation_id,
+            acp::CreateElicitationResponse::new(acp::ElicitationAction::Cancel),
+            cx,
+        );
+    }
+
+    fn respond_to_request_elicitation(
+        &mut self,
+        elicitation_id: ElicitationEntryId,
+        response: acp::CreateElicitationResponse,
+        cx: &mut Context,
+    ) {
+        self.request_elicitation_form_states.remove(&elicitation_id);
+        if let Some(store) = self.request_elicitation_store() {
+            store.update(cx, |store, cx| {
+                store.respond_to_elicitation(&elicitation_id, response, cx);
+            });
+        }
+        cx.notify();
+    }
+
+    fn cancel_request_elicitations(&mut self, cx: &mut App) {
+        self.request_elicitation_form_states.clear();
+        if let Some(store) = self.request_elicitation_store() {
+            store.update(cx, |store, cx| store.clear(cx));
+        }
+    }
+
+    fn clear_resolved_request_elicitations(&mut self, cx: &mut App) {
+        if let Some(connection) = self.request_elicitation_connection() {
+            self.clear_resolved_request_elicitations_for_connection(&connection, cx);
+        }
+    }
+
+    fn clear_resolved_request_elicitations_for_connection(
+        &mut self,
+        connection: &Rc,
+        cx: &mut App,
+    ) {
+        let Some(store) = connection.request_elicitations() else {
+            return;
+        };
+        let cleared_ids = store.update(cx, |store, cx| store.clear_resolved(cx));
+        for id in cleared_ids {
+            self.request_elicitation_form_states.remove(&id);
+        }
+    }
+
     fn emit_load_error_telemetry(&self, error: &LoadError) {
         let error_kind = match error {
             LoadError::Unsupported { .. } => "unsupported",
@@ -2793,6 +3246,7 @@ 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));
         }
@@ -2829,24 +3283,27 @@ impl ConversationView {
                         if let Some(active) = this.root_thread_view() {
                             active.update(cx, |active, cx| active.handle_thread_error(err, cx));
                         }
-                    } else 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()
-                            && view
-                                .read(cx)
-                                .message_editor
-                                .focus_handle(cx)
-                                .is_focused(window)
-                        {
-                            this.focus_handle.focus(window, cx)
+                    } else {
+                        this.cancel_request_elicitations(cx);
+                        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()
+                                && view
+                                    .read(cx)
+                                    .message_editor
+                                    .focus_handle(cx)
+                                    .is_focused(window)
+                            {
+                                this.focus_handle.focus(window, cx)
+                            }
+                            cx.notify();
                         }
-                        cx.notify();
                     }
                     drop(this.auth_task.take());
                 })
@@ -2936,71 +3393,85 @@ impl ConversationView {
 
 impl Render for ConversationView {
     fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
-        v_flex()
-            .track_focus(&self.focus_handle)
-            .size_full()
-            .bg(cx.theme().colors().panel_background)
-            .child(match &self.server_state {
-                ServerState::Loading { .. } => {
-                    let label_text = self
-                        .loading_status
-                        .clone()
-                        .unwrap_or_else(|| "Loading…".into());
-                    v_flex()
-                        .flex_1()
-                        .size_full()
-                        .items_center()
-                        .justify_center()
-                        .child(
-                            Label::new(label_text).color(Color::Muted).with_animation(
-                                "loading-agent-label",
-                                Animation::new(Duration::from_secs(2))
-                                    .repeat()
-                                    .with_easing(pulsating_between(0.3, 0.7)),
-                                |label, delta| label.alpha(delta),
-                            ),
-                        )
-                        .into_any()
-                }
-                ServerState::LoadError { error: e, .. } => v_flex()
+        self.sync_request_elicitation_states(window, cx);
+        let request_elicitation_connection = self.request_elicitation_connection();
+        let active_thread_renders_request_elicitations =
+            self.active_thread_renders_request_elicitations();
+        let content = match &self.server_state {
+            ServerState::Loading { .. } => {
+                let label_text = self
+                    .loading_status
+                    .clone()
+                    .unwrap_or_else(|| "Loading…".into());
+                v_flex()
                     .flex_1()
                     .size_full()
                     .items_center()
-                    .justify_end()
-                    .child(self.render_load_error(e, window, cx))
-                    .into_any(),
-                ServerState::Connected(ConnectedServerState {
+                    .justify_center()
+                    .child(
+                        Label::new(label_text).color(Color::Muted).with_animation(
+                            "loading-agent-label",
+                            Animation::new(Duration::from_secs(2))
+                                .repeat()
+                                .with_easing(pulsating_between(0.3, 0.7)),
+                            |label, delta| label.alpha(delta),
+                        ),
+                    )
+                    .into_any()
+            }
+            ServerState::LoadError { error: e, .. } => v_flex()
+                .flex_1()
+                .size_full()
+                .items_center()
+                .justify_end()
+                .child(self.render_load_error(e, window, cx))
+                .into_any(),
+            ServerState::Connected(ConnectedServerState {
+                connection,
+                auth_state:
+                    AuthState::Unauthenticated {
+                        description,
+                        configuration_view,
+                        pending_auth_method,
+                        _subscription,
+                    },
+                ..
+            }) => v_flex()
+                .flex_1()
+                .size_full()
+                .justify_end()
+                .child(self.render_auth_required_state(
                     connection,
-                    auth_state:
-                        AuthState::Unauthenticated {
-                            description,
-                            configuration_view,
-                            pending_auth_method,
-                            _subscription,
-                        },
-                    ..
-                }) => v_flex()
-                    .flex_1()
-                    .size_full()
-                    .justify_end()
-                    .child(self.render_auth_required_state(
-                        connection,
-                        description.as_ref(),
-                        configuration_view.as_ref(),
-                        pending_auth_method.as_ref(),
-                        window,
-                        cx,
-                    ))
-                    .into_any_element(),
-                ServerState::Connected(connected) => {
-                    if let Some(view) = connected.active_view() {
-                        view.clone().into_any_element()
-                    } else {
-                        debug_panic!("This state should never be reached");
-                        div().into_any_element()
-                    }
+                    description.as_ref(),
+                    configuration_view.as_ref(),
+                    pending_auth_method.as_ref(),
+                    window,
+                    cx,
+                ))
+                .into_any_element(),
+            ServerState::Connected(connected) => {
+                if let Some(view) = connected.active_view() {
+                    view.clone().into_any_element()
+                } else {
+                    debug_panic!("This state should never be reached");
+                    div().into_any_element()
                 }
-            })
+            }
+        };
+
+        v_flex()
+            .track_focus(&self.focus_handle)
+            .size_full()
+            .bg(cx.theme().colors().panel_background)
+            .child(v_flex().flex_1().min_h_0().child(content))
+            .when(!active_thread_renders_request_elicitations, |this| {
+                this.children(request_elicitation_connection.as_ref().map_or_else(
+                    Vec::new,
+                    |connection| {
+                        self.render_request_elicitations(connection, cx.entity().downgrade(), cx)
+                    },
+                ))
+            })
     }
 }
 
@@ -3227,7 +3698,7 @@ pub(crate) mod tests {
     use agent_servers::FakeAcpAgentServer;
     use editor::MultiBufferOffset;
     use editor::actions::Paste;
-    use feature_flags::FeatureFlagAppExt as _;
+    use feature_flags::{FeatureFlag as _, FeatureFlagAppExt as _};
     use fs::FakeFs;
     use gpui::{ClipboardItem, EventEmitter, TestAppContext, VisualTestContext, point, size};
     use parking_lot::Mutex;
@@ -3273,6 +3744,192 @@ pub(crate) mod tests {
         assert!(!weak_view.is_upgradable());
     }
 
+    #[gpui::test]
+    async fn test_drop_preserves_shared_pending_request_elicitations(cx: &mut TestAppContext) {
+        init_test(cx);
+        cx.update(|cx| {
+            cx.update_flags(true, vec![AcpBetaFeatureFlag::NAME.to_string()]);
+        });
+
+        let response = Arc::new(Mutex::new(None));
+        let server = ReleaseRequestElicitationServer {
+            response: response.clone(),
+        };
+        let (conversation_view, cx) = setup_conversation_view(server, cx).await;
+        let _connection = conversation_view
+            .read_with(cx, |view, _cx| view.request_elicitation_connection())
+            .expect("conversation should have an active connection");
+        let store = _connection
+            .request_elicitations()
+            .expect("connection should expose request elicitations");
+        store.read_with(cx, |store, _cx| {
+            assert_eq!(
+                store.elicitations().len(),
+                1,
+                "test should start with one pending request elicitation"
+            );
+        });
+
+        assert_eq!(*response.lock(), None);
+        let weak_view = conversation_view.downgrade();
+        drop(conversation_view);
+        cx.update(|_, _| {});
+        cx.run_until_parked();
+
+        assert!(!weak_view.is_upgradable());
+        store.read_with(cx, |store, _cx| {
+            assert_eq!(
+                store.elicitations().len(),
+                1,
+                "view release should not clear connection-wide request elicitations"
+            );
+        });
+        assert_eq!(*response.lock(), None);
+
+        store.update(cx, |store, cx| store.clear(cx));
+        cx.run_until_parked();
+        assert!(matches!(
+            response.lock().as_ref(),
+            Some(acp::ElicitationAction::Cancel)
+        ));
+    }
+
+    #[gpui::test]
+    async fn test_state_transition_preserves_shared_pending_request_elicitations(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+        cx.update(|cx| {
+            cx.update_flags(true, vec![AcpBetaFeatureFlag::NAME.to_string()]);
+        });
+
+        let response = Arc::new(Mutex::new(None));
+        let server = ReleaseRequestElicitationServer {
+            response: response.clone(),
+        };
+        let (conversation_view, cx) = setup_conversation_view(server, cx).await;
+        let connection = conversation_view
+            .read_with(cx, |view, _cx| view.request_elicitation_connection())
+            .expect("conversation should have an active connection");
+        let store = connection
+            .request_elicitations()
+            .expect("connection should expose request elicitations");
+        store.read_with(cx, |store, _cx| {
+            assert_eq!(
+                store.elicitations().len(),
+                1,
+                "test should start with one pending request elicitation"
+            );
+        });
+
+        conversation_view.update(cx, |view, cx| {
+            view.set_server_state(
+                ServerState::LoadError {
+                    error: LoadError::Other("load failed".into()),
+                },
+                cx,
+            );
+        });
+        cx.run_until_parked();
+
+        store.read_with(cx, |store, _cx| {
+            assert_eq!(
+                store.elicitations().len(),
+                1,
+                "leaving a connection should not clear connection-wide request elicitations"
+            );
+        });
+        assert_eq!(*response.lock(), None);
+
+        store.update(cx, |store, cx| store.clear(cx));
+        cx.run_until_parked();
+        assert!(matches!(
+            response.lock().as_ref(),
+            Some(acp::ElicitationAction::Cancel)
+        ));
+    }
+
+    #[gpui::test]
+    async fn test_successful_session_creation_clears_resolved_request_elicitations(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+        cx.update(|cx| {
+            cx.update_flags(true, vec![AcpBetaFeatureFlag::NAME.to_string()]);
+        });
+
+        let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
+        let response = Arc::new(Mutex::new(None));
+        let server = SessionCreationRequestElicitationServer {
+            store: store.clone(),
+            response: response.clone(),
+        };
+        let (conversation_view, cx) = setup_conversation_view(server, cx).await;
+        let first_request_id = acp::RequestId::Number(1);
+        let second_request_id = acp::RequestId::Number(2);
+        let first_elicitation_id = store.read_with(cx, |store, _cx| {
+            assert_eq!(
+                store.elicitations().len(),
+                2,
+                "session creation should be waiting on one prompt with another prompt still pending"
+            );
+            store
+                .elicitations()
+                .iter()
+                .find_map(|elicitation| {
+                    let acp::ElicitationScope::Request(scope) = elicitation.request.scope() else {
+                        return None;
+                    };
+                    (&scope.request_id == &first_request_id).then(|| elicitation.id.clone())
+                })
+                .expect("first request-scoped elicitation should exist")
+        });
+
+        store.update(cx, |store, cx| {
+            store.respond_to_elicitation(
+                &first_elicitation_id,
+                acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
+                    acp::ElicitationAcceptAction::new(),
+                )),
+                cx,
+            );
+        });
+        cx.run_until_parked();
+
+        assert!(matches!(
+            response.lock().as_ref(),
+            Some(acp::ElicitationAction::Accept(_))
+        ));
+        conversation_view.read_with(cx, |view, _cx| {
+            let connected = view
+                .as_connected()
+                .expect("session creation should complete successfully");
+            assert!(
+                connected.active_id.is_some(),
+                "successful session creation should install an active thread"
+            );
+        });
+        store.read_with(cx, |store, _cx| {
+            let [remaining] = store.elicitations() else {
+                panic!(
+                    "expected only the pending request elicitation to remain, got {:?}",
+                    store.elicitations()
+                );
+            };
+            let acp::ElicitationScope::Request(scope) = remaining.request.scope() else {
+                panic!("expected request-scoped elicitation");
+            };
+            assert_eq!(scope.request_id, second_request_id);
+            assert!(matches!(
+                remaining.status,
+                ElicitationStatus::Pending { .. }
+            ));
+        });
+
+        store.update(cx, |store, cx| store.clear(cx));
+        cx.run_until_parked();
+    }
+
     #[gpui::test]
     async fn test_external_source_prompt_requires_manual_send(cx: &mut TestAppContext) {
         init_test(cx);
@@ -3656,6 +4313,31 @@ pub(crate) mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_thread_view_seeds_existing_elicitation_form_state(cx: &mut TestAppContext) {
+        init_test(cx);
+        cx.update(|cx| {
+            cx.update_flags(true, vec![AcpBetaFeatureFlag::NAME.to_string()]);
+        });
+
+        let connection = PreloadedElicitationConnection::default();
+        let elicitation_id = connection.elicitation_id.clone();
+        let (conversation_view, cx) =
+            setup_conversation_view(StubAgentServer::new(connection), cx).await;
+
+        let elicitation_id = elicitation_id
+            .lock()
+            .clone()
+            .expect("connection should preload an elicitation");
+        let active_thread = active_thread(&conversation_view, cx);
+        active_thread.read_with(cx, |thread, _cx| {
+            assert!(
+                thread.has_elicitation_form_state(&elicitation_id),
+                "pending form elicitations that predate ThreadView construction should be usable"
+            );
+        });
+    }
+
     #[gpui::test]
     async fn test_resume_without_history_adds_notice(cx: &mut TestAppContext) {
         init_test(cx);
@@ -4120,6 +4802,10 @@ pub(crate) mod tests {
                 connected.active_id.is_none(),
                 "There should be no active thread since no session was created"
             );
+            assert!(
+                !view.active_thread_renders_request_elicitations(),
+                "request elicitations should render outside ThreadView when no thread exists"
+            );
             assert!(
                 connected.threads.is_empty(),
                 "There should be no threads since no session was created"
@@ -4159,6 +4845,10 @@ pub(crate) mod tests {
                 connected.active_id.is_some(),
                 "There should be an active thread after successful auth"
             );
+            assert!(
+                view.active_thread_renders_request_elicitations(),
+                "request elicitations should render inside ThreadView while authenticated"
+            );
             assert_eq!(
                 connected.threads.len(),
                 1,
@@ -4189,6 +4879,14 @@ pub(crate) mod tests {
                 !view.supports_logout(),
                 "Logout should be hidden after logout"
             );
+            assert!(
+                view.active_thread().is_some(),
+                "The existing thread should still exist after logout"
+            );
+            assert!(
+                !view.active_thread_renders_request_elicitations(),
+                "Unauthenticated auth UI should render request elicitations outside ThreadView"
+            );
         });
     }
 
@@ -5225,6 +5923,330 @@ pub(crate) mod tests {
         })
     }
 
+    #[derive(Clone, Default)]
+    struct PreloadedElicitationConnection {
+        elicitation_id: Arc>>,
+    }
+
+    impl AgentConnection for PreloadedElicitationConnection {
+        fn agent_id(&self) -> AgentId {
+            AgentId::new("preloaded-elicitation")
+        }
+
+        fn telemetry_id(&self) -> SharedString {
+            "preloaded-elicitation".into()
+        }
+
+        fn new_session(
+            self: Rc,
+            project: Entity,
+            _work_dirs: PathList,
+            cx: &mut App,
+        ) -> Task>> {
+            let session_id = acp::SessionId::new("new-session");
+            let thread = build_test_thread(
+                self.clone(),
+                project,
+                "PreloadedElicitationConnection",
+                session_id.clone(),
+                cx,
+            );
+            thread.update(cx, |thread, cx| {
+                thread
+                    .request_elicitation(
+                        acp::CreateElicitationRequest::new(
+                            acp::ElicitationFormMode::new(
+                                acp::ElicitationSessionScope::new(session_id),
+                                acp::ElicitationSchema::new().string("name", true),
+                            ),
+                            "Provide a name",
+                        ),
+                        cx,
+                    )
+                    .expect("preloaded elicitation should be accepted")
+                    .detach();
+            });
+            let elicitation_id = thread.read_with(cx, |thread, _cx| {
+                thread.entries().iter().find_map(|entry| {
+                    if let AgentThreadEntry::Elicitation(elicitation_id) = entry {
+                        Some(elicitation_id.clone())
+                    } else {
+                        None
+                    }
+                })
+            });
+            *self.elicitation_id.lock() = elicitation_id;
+            Task::ready(Ok(thread))
+        }
+
+        fn auth_methods(&self) -> &[acp::AuthMethod] {
+            &[]
+        }
+
+        fn authenticate(
+            &self,
+            _method_id: acp::AuthMethodId,
+            _cx: &mut App,
+        ) -> Task> {
+            Task::ready(Ok(()))
+        }
+
+        fn prompt(
+            &self,
+            _params: acp::PromptRequest,
+            _cx: &mut App,
+        ) -> Task> {
+            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
+        }
+
+        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
+
+        fn into_any(self: Rc) -> Rc {
+            self
+        }
+    }
+
+    struct SessionCreationRequestElicitationServer {
+        store: Entity,
+        response: Arc>>,
+    }
+
+    impl AgentServer for SessionCreationRequestElicitationServer {
+        fn logo(&self) -> ui::IconName {
+            ui::IconName::ZedAgent
+        }
+
+        fn agent_id(&self) -> AgentId {
+            "SessionCreationRequestElicitation".into()
+        }
+
+        fn connect(
+            &self,
+            _delegate: AgentServerDelegate,
+            _project: Entity,
+            _cx: &mut App,
+        ) -> Task>> {
+            let connection = SessionCreationRequestElicitationConnection {
+                store: self.store.clone(),
+                response: self.response.clone(),
+            };
+            Task::ready(Ok(Rc::new(connection)))
+        }
+
+        fn into_any(self: Rc) -> Rc {
+            self
+        }
+    }
+
+    struct SessionCreationRequestElicitationConnection {
+        store: Entity,
+        response: Arc>>,
+    }
+
+    impl AgentConnection for SessionCreationRequestElicitationConnection {
+        fn agent_id(&self) -> AgentId {
+            AgentId::new("session-creation-request-elicitation")
+        }
+
+        fn telemetry_id(&self) -> SharedString {
+            "session-creation-request-elicitation".into()
+        }
+
+        fn new_session(
+            self: Rc,
+            project: Entity,
+            _work_dirs: PathList,
+            cx: &mut App,
+        ) -> Task>> {
+            let thread = build_test_thread(
+                self.clone(),
+                project,
+                "SessionCreationRequestElicitationConnection",
+                acp::SessionId::new("session-creation-request-elicitation-session"),
+                cx,
+            );
+            let first_response_task = self.store.update(cx, |store, cx| {
+                store
+                    .request_elicitation(
+                        acp::CreateElicitationRequest::new(
+                            acp::ElicitationFormMode::new(
+                                acp::ElicitationRequestScope::new(acp::RequestId::Number(1)),
+                                acp::ElicitationSchema::new().string("name", true),
+                            ),
+                            "Provide a name",
+                        ),
+                        cx,
+                    )
+                    .expect("first request-scoped elicitation should be accepted")
+            });
+            self.store
+                .update(cx, |store, cx| {
+                    store
+                        .request_elicitation(
+                            acp::CreateElicitationRequest::new(
+                                acp::ElicitationFormMode::new(
+                                    acp::ElicitationRequestScope::new(acp::RequestId::Number(2)),
+                                    acp::ElicitationSchema::new().string("account", true),
+                                ),
+                                "Provide an account",
+                            ),
+                            cx,
+                        )
+                        .expect("second request-scoped elicitation should be accepted")
+                })
+                .detach();
+
+            let response = self.response.clone();
+            cx.spawn(async move |_cx| {
+                let elicitation_response = first_response_task.await;
+                *response.lock() = Some(elicitation_response.action);
+                Ok(thread)
+            })
+        }
+
+        fn request_elicitations(&self) -> Option> {
+            Some(self.store.clone())
+        }
+
+        fn auth_methods(&self) -> &[acp::AuthMethod] {
+            &[]
+        }
+
+        fn authenticate(
+            &self,
+            _method_id: acp::AuthMethodId,
+            _cx: &mut App,
+        ) -> Task> {
+            Task::ready(Ok(()))
+        }
+
+        fn prompt(
+            &self,
+            _params: acp::PromptRequest,
+            _cx: &mut App,
+        ) -> Task> {
+            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
+        }
+
+        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
+
+        fn into_any(self: Rc) -> Rc {
+            self
+        }
+    }
+
+    struct ReleaseRequestElicitationServer {
+        response: Arc>>,
+    }
+
+    impl AgentServer for ReleaseRequestElicitationServer {
+        fn logo(&self) -> ui::IconName {
+            ui::IconName::ZedAgent
+        }
+
+        fn agent_id(&self) -> AgentId {
+            "ReleaseRequestElicitation".into()
+        }
+
+        fn connect(
+            &self,
+            _delegate: AgentServerDelegate,
+            _project: Entity,
+            cx: &mut App,
+        ) -> Task>> {
+            let connection = ReleaseRequestElicitationConnection {
+                store: cx.new(|_| ElicitationStore::default()),
+                response: self.response.clone(),
+            };
+            Task::ready(Ok(Rc::new(connection)))
+        }
+
+        fn into_any(self: Rc) -> Rc {
+            self
+        }
+    }
+
+    struct ReleaseRequestElicitationConnection {
+        store: Entity,
+        response: Arc>>,
+    }
+
+    impl AgentConnection for ReleaseRequestElicitationConnection {
+        fn agent_id(&self) -> AgentId {
+            AgentId::new("release-request-elicitation")
+        }
+
+        fn telemetry_id(&self) -> SharedString {
+            "release-request-elicitation".into()
+        }
+
+        fn new_session(
+            self: Rc,
+            project: Entity,
+            _work_dirs: PathList,
+            cx: &mut App,
+        ) -> Task>> {
+            let thread = build_test_thread(
+                self.clone(),
+                project,
+                "ReleaseRequestElicitationConnection",
+                acp::SessionId::new("release-request-elicitation-session"),
+                cx,
+            );
+            let response_task = self.store.update(cx, |store, cx| {
+                store
+                    .request_elicitation(
+                        acp::CreateElicitationRequest::new(
+                            acp::ElicitationFormMode::new(
+                                acp::ElicitationRequestScope::new(acp::RequestId::Number(1)),
+                                acp::ElicitationSchema::new().string("name", true),
+                            ),
+                            "Provide a name",
+                        ),
+                        cx,
+                    )
+                    .expect("request-scoped elicitation should be accepted")
+            });
+            let response = self.response.clone();
+            cx.spawn(async move |_cx| {
+                let elicitation_response = response_task.await;
+                *response.lock() = Some(elicitation_response.action);
+            })
+            .detach();
+            Task::ready(Ok(thread))
+        }
+
+        fn request_elicitations(&self) -> Option> {
+            Some(self.store.clone())
+        }
+
+        fn auth_methods(&self) -> &[acp::AuthMethod] {
+            &[]
+        }
+
+        fn authenticate(
+            &self,
+            _method_id: acp::AuthMethodId,
+            _cx: &mut App,
+        ) -> Task> {
+            Task::ready(Ok(()))
+        }
+
+        fn prompt(
+            &self,
+            _params: acp::PromptRequest,
+            _cx: &mut App,
+        ) -> Task> {
+            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
+        }
+
+        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
+
+        fn into_any(self: Rc) -> Rc {
+            self
+        }
+    }
+
     #[derive(Clone)]
     struct ResumeOnlyAgentConnection;
 
diff --git a/crates/agent_ui/src/conversation_view/elicitation.rs b/crates/agent_ui/src/conversation_view/elicitation.rs
new file mode 100644
index 00000000000000..ae3f3a81b85517
--- /dev/null
+++ b/crates/agent_ui/src/conversation_view/elicitation.rs
@@ -0,0 +1,1642 @@
+use acp_thread::{Elicitation, ElicitationEntryId, ElicitationStatus};
+use agent_client_protocol::schema::v1 as acp;
+use collections::{HashMap, HashSet};
+use component::{Component, ComponentScope, example_group_with_title, single_example};
+use editor::Editor;
+use futures::channel::oneshot;
+use gpui::{AnyElement, App, Div, Empty, Entity, Hsla, SharedString, Window, div};
+use std::collections::BTreeMap;
+use std::rc::Rc;
+use ui::{
+    Button, Checkbox, Color, Icon, IconName, IconSize, Indicator, Label, LabelSize, ToggleState,
+    prelude::*,
+};
+
+#[derive(Clone)]
+struct ElicitationOption {
+    value: String,
+    label: SharedString,
+}
+
+enum ElicitationFieldState {
+    Text(Entity),
+    Boolean(bool),
+    SingleSelect { value: Option },
+    MultiSelect(HashSet),
+}
+
+pub(crate) struct ElicitationFormState {
+    fields: HashMap,
+    field_errors: HashMap,
+}
+
+impl ElicitationFormState {
+    pub(crate) fn new(schema: &acp::ElicitationSchema, window: &mut Window, cx: &mut App) -> Self {
+        let required = schema.required.as_deref().unwrap_or_default();
+        let mut fields = HashMap::default();
+
+        for (name, property) in &schema.properties {
+            let is_required = required.iter().any(|required| required == name);
+            let field = match property {
+                acp::ElicitationPropertySchema::String(schema) => {
+                    let options = single_select_options(schema);
+                    if options.is_empty() {
+                        let editor = cx.new(|cx| {
+                            let mut editor = Editor::single_line(window, cx);
+                            if let Some(default) = &schema.default {
+                                editor.set_text(default.clone(), window, cx);
+                            }
+                            editor
+                        });
+                        ElicitationFieldState::Text(editor)
+                    } else {
+                        let value = single_select_default_value(schema, &options).or_else(|| {
+                            is_required
+                                .then(|| options.first().map(|option| option.value.clone()))
+                                .flatten()
+                        });
+                        ElicitationFieldState::SingleSelect { value }
+                    }
+                }
+                acp::ElicitationPropertySchema::Number(schema) => {
+                    let editor = cx.new(|cx| {
+                        let mut editor = Editor::single_line(window, cx);
+                        if let Some(default) = schema.default {
+                            editor.set_text(default.to_string(), window, cx);
+                        }
+                        editor
+                    });
+                    ElicitationFieldState::Text(editor)
+                }
+                acp::ElicitationPropertySchema::Integer(schema) => {
+                    let editor = cx.new(|cx| {
+                        let mut editor = Editor::single_line(window, cx);
+                        if let Some(default) = schema.default {
+                            editor.set_text(default.to_string(), window, cx);
+                        }
+                        editor
+                    });
+                    ElicitationFieldState::Text(editor)
+                }
+                acp::ElicitationPropertySchema::Boolean(schema) => {
+                    ElicitationFieldState::Boolean(schema.default.unwrap_or(false))
+                }
+                acp::ElicitationPropertySchema::Array(schema) => {
+                    ElicitationFieldState::MultiSelect(
+                        schema
+                            .default
+                            .clone()
+                            .unwrap_or_default()
+                            .into_iter()
+                            .collect(),
+                    )
+                }
+                _ => continue,
+            };
+            fields.insert(name.clone(), field);
+        }
+
+        Self {
+            fields,
+            field_errors: HashMap::default(),
+        }
+    }
+
+    pub(crate) fn collect(
+        &self,
+        schema: &acp::ElicitationSchema,
+        cx: &App,
+    ) -> Result, HashMap> {
+        let required = schema.required.as_deref().unwrap_or_default();
+        let mut content = BTreeMap::new();
+        let mut errors = HashMap::default();
+
+        for (name, property) in &schema.properties {
+            let is_required = required.iter().any(|required| required == name);
+            let Some(field) = self.fields.get(name) else {
+                continue;
+            };
+
+            let field_content = match (property, field) {
+                (
+                    acp::ElicitationPropertySchema::String(schema),
+                    ElicitationFieldState::Text(editor),
+                ) => {
+                    let value = editor.read(cx).text(cx).to_string();
+                    if value.is_empty() {
+                        if is_required {
+                            Err(format!("{} is required", property_title(name, property)).into())
+                        } else {
+                            Ok(None)
+                        }
+                    } else {
+                        validate_string_value(property_title(name, property), schema, &value)
+                            .map(|()| Some(value.into()))
+                    }
+                }
+                (
+                    acp::ElicitationPropertySchema::String(schema),
+                    ElicitationFieldState::SingleSelect { value },
+                ) => {
+                    if let Some(value) = value {
+                        validate_single_select_value(property_title(name, property), schema, value)
+                            .and_then(|()| {
+                                validate_string_value(property_title(name, property), schema, value)
+                            })
+                            .map(|()| Some(value.clone().into()))
+                    } else if is_required {
+                        Err(format!("{} is required", property_title(name, property)).into())
+                    } else {
+                        Ok(None)
+                    }
+                }
+                (
+                    acp::ElicitationPropertySchema::Number(schema),
+                    ElicitationFieldState::Text(editor),
+                ) => {
+                    let value = editor.read(cx).text(cx).trim().to_string();
+                    if value.is_empty() {
+                        if is_required {
+                            Err(format!("{} is required", property_title(name, property)).into())
+                        } else {
+                            Ok(None)
+                        }
+                    } else {
+                        validate_number_value(property_title(name, property), schema, &value)
+                            .map(|parsed| Some(parsed.into()))
+                    }
+                }
+                (
+                    acp::ElicitationPropertySchema::Integer(schema),
+                    ElicitationFieldState::Text(editor),
+                ) => {
+                    let value = editor.read(cx).text(cx).trim().to_string();
+                    if value.is_empty() {
+                        if is_required {
+                            Err(format!("{} is required", property_title(name, property)).into())
+                        } else {
+                            Ok(None)
+                        }
+                    } else {
+                        validate_integer_value(property_title(name, property), schema, &value)
+                            .map(|parsed| Some(parsed.into()))
+                    }
+                }
+                (
+                    acp::ElicitationPropertySchema::Boolean(schema),
+                    ElicitationFieldState::Boolean(value),
+                ) => {
+                    if is_required || *value || schema.default.is_some() {
+                        Ok(Some((*value).into()))
+                    } else {
+                        Ok(None)
+                    }
+                }
+                (
+                    acp::ElicitationPropertySchema::Array(schema),
+                    ElicitationFieldState::MultiSelect(selected),
+                ) => {
+                    let mut values = multi_select_options(schema)
+                        .into_iter()
+                        .filter_map(|option| {
+                            selected.contains(&option.value).then_some(option.value)
+                        })
+                        .collect::>();
+                    values.sort();
+                    if values.is_empty() && !is_required {
+                        Ok(None)
+                    } else if schema
+                        .min_items
+                        .is_some_and(|min_items| values.len() < min_items as usize)
+                    {
+                        Err(
+                            format!("{} needs more selections", property_title(name, property))
+                                .into(),
+                        )
+                    } else if schema
+                        .max_items
+                        .is_some_and(|max_items| values.len() > max_items as usize)
+                    {
+                        Err(
+                            format!("{} has too many selections", property_title(name, property))
+                                .into(),
+                        )
+                    } else {
+                        Ok(Some(values.into()))
+                    }
+                }
+                _ => Ok(None),
+            };
+
+            match field_content {
+                Ok(Some(value)) => {
+                    content.insert(name.clone(), value);
+                }
+                Ok(None) => {}
+                Err(error) => {
+                    errors.insert(name.clone(), error);
+                }
+            }
+        }
+
+        if errors.is_empty() {
+            Ok(content)
+        } else {
+            Err(errors)
+        }
+    }
+
+    pub(crate) fn set_errors(&mut self, errors: HashMap) {
+        self.field_errors = errors;
+    }
+
+    pub(crate) fn set_field_error(
+        &mut self,
+        field_name: impl Into,
+        error: impl Into,
+    ) {
+        self.field_errors.insert(field_name.into(), error.into());
+    }
+
+    pub(crate) fn set_boolean(&mut self, field_name: &str, value: bool) {
+        if let Some(ElicitationFieldState::Boolean(field)) = self.fields.get_mut(field_name) {
+            *field = value;
+            self.field_errors.remove(field_name);
+        }
+    }
+
+    pub(crate) fn set_single_select(&mut self, field_name: &str, value: String) {
+        if let Some(ElicitationFieldState::SingleSelect { value: selected }) =
+            self.fields.get_mut(field_name)
+        {
+            *selected = Some(value);
+            self.field_errors.remove(field_name);
+        }
+    }
+
+    pub(crate) fn set_multi_select(&mut self, field_name: &str, value: String, selected: bool) {
+        if let Some(ElicitationFieldState::MultiSelect(values)) = self.fields.get_mut(field_name) {
+            if selected {
+                values.insert(value);
+            } else {
+                values.remove(&value);
+            }
+            self.field_errors.remove(field_name);
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use gpui::TestAppContext;
+
+    #[test]
+    fn string_validation_rejects_email_format_mismatch() {
+        let schema = acp::StringPropertySchema::email();
+
+        validate_string_value("Email".into(), &schema, "user@example.com")
+            .expect("valid email should be accepted");
+        assert_eq!(
+            validate_string_value("Email".into(), &schema, "not-an-email")
+                .expect_err("invalid email should be rejected")
+                .to_string(),
+            "Email must be an email address"
+        );
+    }
+
+    #[test]
+    fn string_validation_rejects_pattern_mismatch() {
+        let schema = acp::StringPropertySchema::new().pattern("^prod-[0-9]+$");
+
+        validate_string_value("Environment".into(), &schema, "prod-42")
+            .expect("matching pattern should be accepted");
+        assert_eq!(
+            validate_string_value("Environment".into(), &schema, "dev-42")
+                .expect_err("pattern mismatch should be rejected")
+                .to_string(),
+            "Environment does not match the requested pattern"
+        );
+    }
+
+    #[test]
+    fn number_validation_rejects_non_finite_values() {
+        let schema = acp::NumberPropertySchema::new();
+
+        assert_eq!(
+            validate_number_value("Amount".into(), &schema, "42.5")
+                .expect("finite number should be accepted"),
+            42.5
+        );
+
+        for value in ["NaN", "inf", "-inf", "1e309"] {
+            assert_eq!(
+                validate_number_value("Amount".into(), &schema, value)
+                    .expect_err("non-finite number should be rejected")
+                    .to_string(),
+                "Amount must be a finite number"
+            );
+        }
+    }
+
+    #[test]
+    fn should_render_pending_and_accepted_url_elicitations() {
+        let pending = Elicitation {
+            id: ElicitationEntryId("pending".into()),
+            request: acp::CreateElicitationRequest::new(
+                acp::ElicitationFormMode::new(
+                    preview_request_scope(0),
+                    acp::ElicitationSchema::new(),
+                ),
+                "Review this request.",
+            ),
+            status: pending_status(),
+        };
+        assert!(should_render_elicitation(&pending));
+
+        let accepted_url = Elicitation {
+            id: ElicitationEntryId("accepted-url".into()),
+            request: acp::CreateElicitationRequest::new(
+                acp::ElicitationUrlMode::new(
+                    preview_request_scope(1),
+                    acp::ElicitationId::new("accepted-url"),
+                    "https://auth.example.com/device",
+                ),
+                "Authorize Zed in your browser.",
+            ),
+            status: ElicitationStatus::Accepted,
+        };
+        assert!(should_render_elicitation(&accepted_url));
+
+        let accepted_form = Elicitation {
+            id: ElicitationEntryId("accepted-form".into()),
+            request: acp::CreateElicitationRequest::new(
+                acp::ElicitationFormMode::new(
+                    preview_request_scope(2),
+                    acp::ElicitationSchema::new(),
+                ),
+                "Review this request.",
+            ),
+            status: ElicitationStatus::Accepted,
+        };
+        assert!(!should_render_elicitation(&accepted_form));
+    }
+
+    #[test]
+    fn display_url_segments_never_elide_url_characters() {
+        let url = format!(
+            "https://auth.example.com/oauth/authorize?state={}",
+            "a".repeat(MAX_URL_DISPLAY_SEGMENT_CHARS * 2)
+        );
+
+        let display_url_segments = display_url_segments(&url)
+            .into_iter()
+            .map(|segment| segment.to_string())
+            .collect::>();
+        assert!(display_url_segments.len() > 1);
+        assert!(
+            !display_url_segments
+                .iter()
+                .any(|segment| segment.contains('…'))
+        );
+        assert!(
+            display_url_segments
+                .iter()
+                .all(|segment| segment.chars().count() <= MAX_URL_DISPLAY_SEGMENT_CHARS)
+        );
+        assert_eq!(display_url_segments.concat(), url);
+    }
+
+    #[test]
+    fn display_url_segments_use_larger_url_boundaries() {
+        let url = "https://auth.example.com/oauth/authorize?client_id=zed-desktop&scope=repository";
+
+        let display_url_segments = display_url_segments(url)
+            .into_iter()
+            .map(|segment| segment.to_string())
+            .collect::>();
+        assert_eq!(display_url_segments.concat(), url);
+        assert_eq!(display_url_segments[0], "https://auth.example.com/");
+        assert!(
+            display_url_segments
+                .iter()
+                .any(|segment| segment.ends_with('?'))
+        );
+        assert!(
+            display_url_segments
+                .iter()
+                .any(|segment| segment.ends_with('&'))
+        );
+    }
+
+    #[gpui::test]
+    fn form_state_preserves_string_whitespace(cx: &mut TestAppContext) {
+        crate::conversation_view::tests::init_test(cx);
+
+        cx.add_window(|window, cx| {
+            let schema = acp::ElicitationSchema::new().property(
+                "token",
+                acp::StringPropertySchema::new()
+                    .title("Token")
+                    .default_value("  secret  "),
+                true,
+            );
+            let form_state = ElicitationFormState::new(&schema, window, cx);
+            let content = form_state
+                .collect(&schema, cx)
+                .expect("string with whitespace should be submitted");
+
+            assert_eq!(
+                content.get("token"),
+                Some(&acp::ElicitationContentValue::String(
+                    "  secret  ".to_string()
+                ))
+            );
+
+            Editor::single_line(window, cx)
+        });
+    }
+
+    #[gpui::test]
+    fn form_state_discards_invalid_optional_single_select_default(cx: &mut TestAppContext) {
+        crate::conversation_view::tests::init_test(cx);
+
+        cx.add_window(|window, cx| {
+            let schema = acp::ElicitationSchema::new().property(
+                "environment",
+                acp::StringPropertySchema::new()
+                    .title("Environment")
+                    .enum_values(vec!["production".to_string(), "staging".to_string()])
+                    .default_value("development"),
+                false,
+            );
+            let form_state = ElicitationFormState::new(&schema, window, cx);
+            let content = form_state
+                .collect(&schema, cx)
+                .expect("invalid optional default should be ignored");
+
+            assert_eq!(content.get("environment"), None);
+
+            Editor::single_line(window, cx)
+        });
+    }
+
+    #[gpui::test]
+    fn form_state_replaces_invalid_required_single_select_default(cx: &mut TestAppContext) {
+        crate::conversation_view::tests::init_test(cx);
+
+        cx.add_window(|window, cx| {
+            let schema = acp::ElicitationSchema::new().property(
+                "environment",
+                acp::StringPropertySchema::new()
+                    .title("Environment")
+                    .enum_values(vec!["production".to_string(), "staging".to_string()])
+                    .default_value("development"),
+                true,
+            );
+            let form_state = ElicitationFormState::new(&schema, window, cx);
+            let content = form_state
+                .collect(&schema, cx)
+                .expect("required select should use the first valid choice");
+
+            assert_eq!(
+                content.get("environment"),
+                Some(&acp::ElicitationContentValue::String(
+                    "production".to_string()
+                ))
+            );
+
+            Editor::single_line(window, cx)
+        });
+    }
+
+    #[gpui::test]
+    fn form_state_rejects_invalid_single_select_value(cx: &mut TestAppContext) {
+        crate::conversation_view::tests::init_test(cx);
+
+        cx.add_window(|window, cx| {
+            let schema = acp::ElicitationSchema::new().property(
+                "environment",
+                acp::StringPropertySchema::new()
+                    .title("Environment")
+                    .enum_values(vec!["production".to_string(), "staging".to_string()]),
+                false,
+            );
+            let mut form_state = ElicitationFormState::new(&schema, window, cx);
+            form_state.set_single_select("environment", "development".to_string());
+
+            let errors = form_state
+                .collect(&schema, cx)
+                .expect_err("invalid selected value should be rejected");
+            assert_eq!(
+                errors
+                    .get("environment")
+                    .expect("environment should have an error")
+                    .to_string(),
+                "Environment must be one of the provided options"
+            );
+
+            Editor::single_line(window, cx)
+        });
+    }
+
+    #[gpui::test]
+    fn form_state_reports_all_validation_errors(cx: &mut TestAppContext) {
+        crate::conversation_view::tests::init_test(cx);
+
+        cx.add_window(|window, cx| {
+            let schema = acp::ElicitationSchema::new()
+                .string("account", true)
+                .property(
+                    "age",
+                    acp::IntegerPropertySchema::new().title("Age").minimum(18),
+                    true,
+                )
+                .property(
+                    "environment",
+                    acp::StringPropertySchema::new()
+                        .title("Environment")
+                        .enum_values(vec!["production".to_string(), "staging".to_string()]),
+                    false,
+                );
+            let mut form_state = ElicitationFormState::new(&schema, window, cx);
+            if let Some(ElicitationFieldState::Text(editor)) = form_state.fields.get("age") {
+                editor.update(cx, |editor, cx| editor.set_text("abc", window, cx));
+            }
+            form_state.set_single_select("environment", "development".to_string());
+
+            let errors = form_state
+                .collect(&schema, cx)
+                .expect_err("all invalid fields should be reported");
+            assert_eq!(
+                errors
+                    .get("account")
+                    .expect("account should have an error")
+                    .to_string(),
+                "account is required"
+            );
+            assert_eq!(
+                errors
+                    .get("age")
+                    .expect("age should have an error")
+                    .to_string(),
+                "Age must be an integer"
+            );
+            assert_eq!(
+                errors
+                    .get("environment")
+                    .expect("environment should have an error")
+                    .to_string(),
+                "Environment must be one of the provided options"
+            );
+
+            Editor::single_line(window, cx)
+        });
+    }
+}
+
+#[derive(RegisterComponent)]
+pub struct ElicitationCardPreview;
+
+impl Component for ElicitationCardPreview {
+    fn scope() -> ComponentScope {
+        ComponentScope::Agent
+    }
+
+    fn description() -> &'static str {
+        "ACP elicitation request cards as rendered in the agent panel."
+    }
+
+    fn preview(window: &mut Window, cx: &mut App) -> AnyElement {
+        v_flex()
+            .gap_6()
+            .children([
+                example_group_with_title(
+                    "Form Requests",
+                    vec![
+                        single_example(
+                            "Pending Form",
+                            render_form_preview(0, pending_status(), &[], window, cx),
+                        )
+                        .width(px(640.)),
+                        single_example(
+                            "Validation Errors",
+                            render_form_preview(
+                                1,
+                                pending_status(),
+                                &[
+                                    ("account_name", "Account name is required"),
+                                    ("environment", "Choose an environment"),
+                                    ("scopes", "Choose at least one access scope"),
+                                ],
+                                window,
+                                cx,
+                            ),
+                        )
+                        .width(px(640.)),
+                    ],
+                )
+                .vertical()
+                .into_any_element(),
+                example_group_with_title(
+                    "URL Requests",
+                    vec![
+                        single_example(
+                            "URL Consent",
+                            render_url_preview(3, pending_status(), window, cx),
+                        )
+                        .width(px(640.)),
+                    ],
+                )
+                .vertical()
+                .into_any_element(),
+                example_group_with_title(
+                    "Terminal States",
+                    vec![
+                        single_example(
+                            "Declined",
+                            render_form_preview(6, ElicitationStatus::Declined, &[], window, cx),
+                        )
+                        .width(px(640.)),
+                        single_example(
+                            "Canceled",
+                            render_form_preview(7, ElicitationStatus::Canceled, &[], window, cx),
+                        )
+                        .width(px(640.)),
+                    ],
+                )
+                .vertical()
+                .into_any_element(),
+            ])
+            .into_any_element()
+    }
+}
+
+fn render_form_preview(
+    entry_ix: usize,
+    status: ElicitationStatus,
+    field_errors: &[(&'static str, &'static str)],
+    window: &mut Window,
+    cx: &mut App,
+) -> AnyElement {
+    let request = acp::CreateElicitationRequest::new(
+        acp::ElicitationFormMode::new(preview_request_scope(entry_ix), preview_form_schema()),
+        "Choose how Zed should connect to this account.",
+    );
+    let mut form_state = matches!(status, ElicitationStatus::Pending { .. }).then(|| {
+        let acp::ElicitationMode::Form(mode) = &request.mode else {
+            unreachable!();
+        };
+        ElicitationFormState::new(&mode.requested_schema, window, cx)
+    });
+    if let Some(form_state) = &mut form_state {
+        for (field_name, error) in field_errors {
+            form_state.set_field_error(*field_name, *error);
+        }
+    }
+
+    render_preview_card(entry_ix, request, status, form_state.as_ref(), cx)
+}
+
+fn preview_url() -> &'static str {
+    "https://auth.example.com/oauth/authorize?client_id=zed-desktop&redirect_uri=zed%3A%2F%2Fagent%2Facp%2Fcallback&scope=profile%20repository%20terminal&state=9b8b0a873a1e4b57b7f9f7b6d2d3d0f4"
+}
+
+fn render_url_preview(
+    entry_ix: usize,
+    status: ElicitationStatus,
+    _window: &mut Window,
+    cx: &mut App,
+) -> AnyElement {
+    let request = acp::CreateElicitationRequest::new(
+        acp::ElicitationUrlMode::new(
+            preview_request_scope(entry_ix),
+            acp::ElicitationId::new(format!("preview-url-{entry_ix}")),
+            preview_url(),
+        ),
+        "Authorize Zed in your browser to finish signing in.",
+    );
+
+    render_preview_card(entry_ix, request, status, None, cx)
+}
+
+fn render_preview_card(
+    entry_ix: usize,
+    request: acp::CreateElicitationRequest,
+    status: ElicitationStatus,
+    form_state: Option<&ElicitationFormState>,
+    cx: &App,
+) -> AnyElement {
+    let elicitation = Elicitation {
+        id: ElicitationEntryId(format!("preview-elicitation-{entry_ix}").into()),
+        request,
+        status,
+    };
+
+    div()
+        .w_full()
+        .max_w(px(640.))
+        .child(
+            ElicitationCard::new(
+                entry_ix,
+                &elicitation,
+                form_state,
+                ElicitationCardHandlers::noop(),
+            )
+            .render(cx),
+        )
+        .into_any_element()
+}
+
+fn pending_status() -> ElicitationStatus {
+    let (respond_tx, _response_rx) = oneshot::channel();
+    ElicitationStatus::Pending { respond_tx }
+}
+
+fn preview_request_scope(index: usize) -> acp::ElicitationRequestScope {
+    acp::ElicitationRequestScope::new(acp::RequestId::Number(index as i64))
+}
+
+fn preview_form_schema() -> acp::ElicitationSchema {
+    acp::ElicitationSchema::new()
+        .property(
+            "account_name",
+            acp::StringPropertySchema::new()
+                .title("Account Name")
+                .description("Used to label this connection in the agent panel.")
+                .default_value("Work"),
+            true,
+        )
+        .property(
+            "environment",
+            acp::StringPropertySchema::new()
+                .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("development", "Development"),
+                ])
+                .default_value("staging"),
+            true,
+        )
+        .property(
+            "scopes",
+            acp::MultiSelectPropertySchema::titled(vec![
+                acp::EnumOption::new("profile", "Profile"),
+                acp::EnumOption::new("repository", "Repository Access"),
+                acp::EnumOption::new("terminal", "Terminal Commands"),
+            ])
+            .title("Access")
+            .description("Choose what the agent can use for this authorization.")
+            .min_items(1)
+            .default_value(vec!["profile".to_string(), "repository".to_string()]),
+            true,
+        )
+        .property(
+            "remember",
+            acp::BooleanPropertySchema::new()
+                .title("Remember Authorization")
+                .description("Store this authorization for future sessions.")
+                .default_value(true),
+            false,
+        )
+}
+
+fn single_select_options(schema: &acp::StringPropertySchema) -> Vec {
+    if let Some(options) = &schema.one_of {
+        return options
+            .iter()
+            .map(|option| ElicitationOption {
+                value: option.value.clone(),
+                label: SharedString::from(option.title.clone()),
+            })
+            .collect();
+    }
+
+    schema
+        .enum_values
+        .as_deref()
+        .unwrap_or_default()
+        .iter()
+        .map(|value| ElicitationOption {
+            value: value.clone(),
+            label: SharedString::from(value.clone()),
+        })
+        .collect()
+}
+
+fn single_select_default_value(
+    schema: &acp::StringPropertySchema,
+    options: &[ElicitationOption],
+) -> Option {
+    schema
+        .default
+        .as_ref()
+        .filter(|default| {
+            options
+                .iter()
+                .any(|option| option.value.as_str() == default.as_str())
+        })
+        .cloned()
+}
+
+fn multi_select_options(schema: &acp::MultiSelectPropertySchema) -> Vec {
+    match &schema.items {
+        acp::MultiSelectItems::Untitled(items) => items
+            .values
+            .iter()
+            .map(|value| ElicitationOption {
+                value: value.clone(),
+                label: SharedString::from(value.clone()),
+            })
+            .collect(),
+        acp::MultiSelectItems::Titled(items) => items
+            .options
+            .iter()
+            .map(|option| ElicitationOption {
+                value: option.value.clone(),
+                label: SharedString::from(option.title.clone()),
+            })
+            .collect(),
+        _ => Vec::new(),
+    }
+}
+
+fn validate_number_value(
+    title: SharedString,
+    schema: &acp::NumberPropertySchema,
+    value: &str,
+) -> Result {
+    let parsed = value
+        .parse::()
+        .map_err(|_| SharedString::from(format!("{title} must be a number")))?;
+    if !parsed.is_finite() {
+        return Err(format!("{title} must be a finite number").into());
+    }
+    if let Some(minimum) = schema.minimum
+        && parsed < minimum
+    {
+        return Err(format!("{title} must be at least {minimum}").into());
+    }
+    if let Some(maximum) = schema.maximum
+        && parsed > maximum
+    {
+        return Err(format!("{title} must be at most {maximum}").into());
+    }
+
+    Ok(parsed)
+}
+
+fn validate_integer_value(
+    title: SharedString,
+    schema: &acp::IntegerPropertySchema,
+    value: &str,
+) -> Result {
+    let parsed = value
+        .parse::()
+        .map_err(|_| SharedString::from(format!("{title} must be an integer")))?;
+    if let Some(minimum) = schema.minimum
+        && parsed < minimum
+    {
+        return Err(format!("{title} must be at least {minimum}").into());
+    }
+    if let Some(maximum) = schema.maximum
+        && parsed > maximum
+    {
+        return Err(format!("{title} must be at most {maximum}").into());
+    }
+
+    Ok(parsed)
+}
+
+fn validate_string_value(
+    title: SharedString,
+    schema: &acp::StringPropertySchema,
+    value: &str,
+) -> Result<(), SharedString> {
+    let length = value.chars().count();
+    if schema
+        .min_length
+        .is_some_and(|min_length| length < min_length as usize)
+    {
+        return Err(format!("{title} is too short").into());
+    }
+    if schema
+        .max_length
+        .is_some_and(|max_length| length > max_length as usize)
+    {
+        return Err(format!("{title} is too long").into());
+    }
+
+    validate_string_pattern_and_format(title, schema, value)
+}
+
+fn validate_single_select_value(
+    title: SharedString,
+    schema: &acp::StringPropertySchema,
+    value: &str,
+) -> Result<(), SharedString> {
+    let options = single_select_options(schema);
+    if options.iter().any(|option| option.value.as_str() == value) {
+        Ok(())
+    } else {
+        Err(format!("{title} must be one of the provided options").into())
+    }
+}
+
+fn validate_string_pattern_and_format(
+    title: SharedString,
+    schema: &acp::StringPropertySchema,
+    value: &str,
+) -> Result<(), SharedString> {
+    if schema.pattern.is_none() && schema.format.and_then(string_format_json_name).is_none() {
+        return Ok(());
+    }
+
+    let mut validation_schema = serde_json::Map::new();
+    validation_schema.insert(
+        "type".to_string(),
+        serde_json::Value::String("string".into()),
+    );
+    if let Some(pattern) = &schema.pattern {
+        validation_schema.insert(
+            "pattern".to_string(),
+            serde_json::Value::String(pattern.clone()),
+        );
+    }
+    if let Some(format) = schema.format.and_then(string_format_json_name) {
+        validation_schema.insert(
+            "format".to_string(),
+            serde_json::Value::String(format.into()),
+        );
+    }
+
+    let validation_schema = serde_json::Value::Object(validation_schema);
+    let validator = jsonschema::options()
+        .should_validate_formats(true)
+        .build(&validation_schema)
+        .map_err(|_| {
+            if schema.pattern.is_some() {
+                format!("{title} has an invalid validation pattern")
+            } else {
+                format!("{title} has an invalid validation format")
+            }
+        })?;
+    if validator.is_valid(&serde_json::Value::String(value.to_string())) {
+        return Ok(());
+    }
+
+    match (
+        schema.pattern.is_some(),
+        schema.format.and_then(string_format_label),
+    ) {
+        (true, Some(_)) => Err(format!("{title} does not match the requested constraints").into()),
+        (true, None) => Err(format!("{title} does not match the requested pattern").into()),
+        (false, Some(format)) => Err(format!("{title} must be {format}").into()),
+        (false, None) => Ok(()),
+    }
+}
+
+fn string_format_json_name(format: acp::StringFormat) -> Option<&'static str> {
+    match format {
+        acp::StringFormat::Email => Some("email"),
+        acp::StringFormat::Uri => Some("uri"),
+        acp::StringFormat::Date => Some("date"),
+        acp::StringFormat::DateTime => Some("date-time"),
+        _ => None,
+    }
+}
+
+fn string_format_label(format: acp::StringFormat) -> Option<&'static str> {
+    match format {
+        acp::StringFormat::Email => Some("an email address"),
+        acp::StringFormat::Uri => Some("a URI"),
+        acp::StringFormat::Date => Some("a date"),
+        acp::StringFormat::DateTime => Some("a date and time"),
+        _ => None,
+    }
+}
+
+fn property_title(name: &str, property: &acp::ElicitationPropertySchema) -> SharedString {
+    let title = match property {
+        acp::ElicitationPropertySchema::String(schema) => schema.title.as_deref(),
+        acp::ElicitationPropertySchema::Number(schema) => schema.title.as_deref(),
+        acp::ElicitationPropertySchema::Integer(schema) => schema.title.as_deref(),
+        acp::ElicitationPropertySchema::Boolean(schema) => schema.title.as_deref(),
+        acp::ElicitationPropertySchema::Array(schema) => schema.title.as_deref(),
+        _ => None,
+    };
+    SharedString::from(title.unwrap_or(name).to_string())
+}
+
+fn property_description(property: &acp::ElicitationPropertySchema) -> Option {
+    match property {
+        acp::ElicitationPropertySchema::String(schema) => schema.description.clone(),
+        acp::ElicitationPropertySchema::Number(schema) => schema.description.clone(),
+        acp::ElicitationPropertySchema::Integer(schema) => schema.description.clone(),
+        acp::ElicitationPropertySchema::Boolean(schema) => schema.description.clone(),
+        acp::ElicitationPropertySchema::Array(schema) => schema.description.clone(),
+        _ => None,
+    }
+    .map(SharedString::from)
+}
+
+type RespondHandler = Rc;
+type OpenUrlHandler = Rc;
+type BooleanHandler = Rc;
+type SelectHandler = Rc;
+type MultiSelectHandler = Rc;
+
+#[derive(Clone)]
+pub(crate) struct ElicitationCardHandlers {
+    on_submit: RespondHandler,
+    on_decline: RespondHandler,
+    on_cancel: RespondHandler,
+    on_open_url: OpenUrlHandler,
+    on_boolean_change: BooleanHandler,
+    on_single_select_change: SelectHandler,
+    on_multi_select_change: MultiSelectHandler,
+}
+
+impl ElicitationCardHandlers {
+    pub(crate) fn new(
+        on_submit: impl Fn(ElicitationEntryId, &mut Window, &mut App) + 'static,
+        on_decline: impl Fn(ElicitationEntryId, &mut Window, &mut App) + 'static,
+        on_cancel: impl Fn(ElicitationEntryId, &mut Window, &mut App) + 'static,
+        on_open_url: impl Fn(ElicitationEntryId, String, &mut Window, &mut App) + 'static,
+        on_boolean_change: impl Fn(ElicitationEntryId, String, bool, &mut App) + 'static,
+        on_single_select_change: impl Fn(ElicitationEntryId, String, String, &mut App) + 'static,
+        on_multi_select_change: impl Fn(ElicitationEntryId, String, String, bool, &mut App) + 'static,
+    ) -> Self {
+        Self {
+            on_submit: Rc::new(on_submit),
+            on_decline: Rc::new(on_decline),
+            on_cancel: Rc::new(on_cancel),
+            on_open_url: Rc::new(on_open_url),
+            on_boolean_change: Rc::new(on_boolean_change),
+            on_single_select_change: Rc::new(on_single_select_change),
+            on_multi_select_change: Rc::new(on_multi_select_change),
+        }
+    }
+
+    pub(crate) fn noop() -> Self {
+        Self::new(
+            |_, _, _| {},
+            |_, _, _| {},
+            |_, _, _| {},
+            |_, _, _, _| {},
+            |_, _, _, _| {},
+            |_, _, _, _| {},
+            |_, _, _, _, _| {},
+        )
+    }
+}
+
+pub(crate) fn should_render_elicitation(elicitation: &Elicitation) -> bool {
+    matches!(
+        (&elicitation.status, &elicitation.request.mode),
+        (ElicitationStatus::Pending { .. }, _)
+            | (ElicitationStatus::Accepted, acp::ElicitationMode::Url(_))
+    )
+}
+
+const MIN_URL_DISPLAY_SEGMENT_CHARS: usize = 16;
+const MAX_URL_DISPLAY_SEGMENT_CHARS: usize = 64;
+
+fn display_url_segments(url: &str) -> Vec {
+    let mut segments = Vec::new();
+    let mut segment = String::new();
+    let mut segment_chars = 0;
+    let mut characters = url.chars().peekable();
+
+    while let Some(character) = characters.next() {
+        segment.push(character);
+        segment_chars += 1;
+
+        let should_split_at_boundary = segment_chars >= MIN_URL_DISPLAY_SEGMENT_CHARS
+            && is_url_display_segment_boundary(character);
+        let should_split_at_length = segment_chars >= MAX_URL_DISPLAY_SEGMENT_CHARS;
+
+        if characters.peek().is_some() && (should_split_at_boundary || should_split_at_length) {
+            segments.push(std::mem::take(&mut segment).into());
+            segment_chars = 0;
+        }
+    }
+
+    if !segment.is_empty() {
+        segments.push(segment.into());
+    }
+
+    segments
+}
+
+fn is_url_display_segment_boundary(character: char) -> bool {
+    matches!(character, '/' | '?' | '&' | '#')
+}
+
+pub(crate) struct ElicitationCard<'a> {
+    entry_ix: usize,
+    elicitation: &'a Elicitation,
+    form_state: Option<&'a ElicitationFormState>,
+    handlers: ElicitationCardHandlers,
+}
+
+impl<'a> ElicitationCard<'a> {
+    pub(crate) fn new(
+        entry_ix: usize,
+        elicitation: &'a Elicitation,
+        form_state: Option<&'a ElicitationFormState>,
+        handlers: ElicitationCardHandlers,
+    ) -> Self {
+        Self {
+            entry_ix,
+            elicitation,
+            form_state,
+            handlers,
+        }
+    }
+
+    pub(crate) fn render(self, cx: &App) -> Div {
+        let border_color = cx.theme().colors().border.opacity(0.8);
+        let header_background = cx
+            .theme()
+            .colors()
+            .element_background
+            .blend(cx.theme().colors().editor_foreground.opacity(0.025));
+        let tool_name_font_size = rems_from_px(13.);
+        let is_pending = matches!(&self.elicitation.status, ElicitationStatus::Pending { .. });
+        let is_accepted_url = matches!(
+            (&self.elicitation.status, &self.elicitation.request.mode),
+            (ElicitationStatus::Accepted, acp::ElicitationMode::Url(_))
+        );
+        let (status_label, status_icon, status_color) = match &self.elicitation.status {
+            ElicitationStatus::Pending { .. } => ("Waiting for input", IconName::Info, Color::Info),
+            ElicitationStatus::Accepted if is_accepted_url => {
+                ("Waiting for completion", IconName::Info, Color::Info)
+            }
+            ElicitationStatus::Accepted => ("Submitted", IconName::Check, Color::Success),
+            ElicitationStatus::Declined => ("Declined", IconName::Close, Color::Muted),
+            ElicitationStatus::Canceled => ("Canceled", IconName::Circle, Color::Muted),
+            ElicitationStatus::Completed => ("Completed", IconName::Check, Color::Success),
+        };
+
+        let body = v_flex()
+            .gap_2()
+            .p_3()
+            .child(Label::new(self.elicitation.request.message.clone()).size(LabelSize::Small));
+        let body = match &self.elicitation.request.mode {
+            acp::ElicitationMode::Form(mode) if is_pending => {
+                body.child(self.render_form(mode, cx))
+            }
+            acp::ElicitationMode::Url(mode) if is_pending || is_accepted_url => {
+                body.child(self.render_url_elicitation(mode))
+            }
+            _ => body,
+        };
+
+        v_flex()
+            .mx_5()
+            .my_1p5()
+            .rounded_md()
+            .border_1()
+            .border_color(border_color)
+            .overflow_hidden()
+            .child(
+                h_flex()
+                    .h_8()
+                    .p_1()
+                    .w_full()
+                    .justify_between()
+                    .bg(header_background)
+                    .child(
+                        h_flex()
+                            .min_w_0()
+                            .gap_1p5()
+                            .px_1()
+                            .child(
+                                Icon::new(status_icon)
+                                    .size(IconSize::Small)
+                                    .color(status_color),
+                            )
+                            .child(
+                                Label::new("Input Requested")
+                                    .size(LabelSize::Custom(tool_name_font_size))
+                                    .truncate(),
+                            ),
+                    )
+                    .child(
+                        Label::new(status_label)
+                            .size(LabelSize::Small)
+                            .color(Color::Muted),
+                    ),
+            )
+            .child(body)
+            .when(is_pending, |this| this.child(self.render_actions(cx)))
+    }
+
+    fn render_form(&self, mode: &acp::ElicitationFormMode, cx: &App) -> AnyElement {
+        let Some(state) = self.form_state else {
+            return Empty.into_any_element();
+        };
+
+        v_flex()
+            .gap_2()
+            .children(mode.requested_schema.properties.iter().filter_map(
+                |(field_name, property)| {
+                    let field = state.fields.get(field_name)?;
+                    Some(self.render_field(
+                        field_name,
+                        property,
+                        field,
+                        state.field_errors.get(field_name),
+                        cx,
+                    ))
+                },
+            ))
+            .into_any_element()
+    }
+
+    fn render_field(
+        &self,
+        field_name: &str,
+        property: &acp::ElicitationPropertySchema,
+        field: &ElicitationFieldState,
+        error: Option<&SharedString>,
+        cx: &App,
+    ) -> AnyElement {
+        let label = property_title(field_name, property);
+        let description = property_description(property);
+        let border_color = cx.theme().colors().border.opacity(0.8);
+        let field_border_color = if error.is_some() {
+            Color::Error.color(cx)
+        } else {
+            border_color
+        };
+        let editor_background = cx.theme().colors().editor_background;
+        let label_color = if error.is_some() {
+            Color::Error
+        } else {
+            Color::Default
+        };
+
+        if let ElicitationFieldState::Boolean(value) = field {
+            let checkbox_state = if *value {
+                ToggleState::Selected
+            } else {
+                ToggleState::Unselected
+            };
+            let next_value = !*value;
+            let on_boolean_change = self.handlers.on_boolean_change.clone();
+            let elicitation_id = self.elicitation.id.clone();
+            let field_name = field_name.to_string();
+            let row_id = format!("elicitation-bool-row-{}-{field_name}", self.entry_ix);
+            let checkbox_id = format!("elicitation-bool-{}-{field_name}", self.entry_ix);
+
+            return v_flex()
+                .gap_1()
+                .child(
+                    h_flex()
+                        .id(row_id)
+                        .w_full()
+                        .items_start()
+                        .gap_1()
+                        .cursor_pointer()
+                        .on_click(move |_, _window, cx| {
+                            on_boolean_change(
+                                elicitation_id.clone(),
+                                field_name.clone(),
+                                next_value,
+                                cx,
+                            );
+                        })
+                        .child(
+                            div()
+                                .mt_0p5()
+                                .child(Checkbox::new(checkbox_id, checkbox_state)),
+                        )
+                        .child(
+                            v_flex()
+                                .gap_0p5()
+                                .child(Label::new(label).size(LabelSize::Small).color(label_color))
+                                .when_some(description, |this, description| {
+                                    this.child(
+                                        Label::new(description)
+                                            .size(LabelSize::Small)
+                                            .color(Color::Muted),
+                                    )
+                                }),
+                        ),
+                )
+                .when_some(error.cloned(), |this, error| {
+                    this.child(Label::new(error).size(LabelSize::Small).color(Color::Error))
+                })
+                .into_any_element();
+        }
+
+        let label = if error.is_some() {
+            Label::new(label).size(LabelSize::Small).color(Color::Error)
+        } else {
+            Label::new(label).size(LabelSize::Small)
+        };
+
+        v_flex()
+            .gap_1()
+            .child(label)
+            .when_some(description, |this, description| {
+                this.child(
+                    Label::new(description)
+                        .size(LabelSize::Small)
+                        .color(Color::Muted),
+                )
+            })
+            .child(match field {
+                ElicitationFieldState::Text(editor) => div()
+                    .rounded_sm()
+                    .border_1()
+                    .border_color(field_border_color)
+                    .bg(editor_background)
+                    .px_1()
+                    .py_0p5()
+                    .text_xs()
+                    .child(editor.clone().into_any_element())
+                    .into_any_element(),
+                ElicitationFieldState::Boolean(_) => Empty.into_any_element(),
+                ElicitationFieldState::SingleSelect { value } => {
+                    let options = match property {
+                        acp::ElicitationPropertySchema::String(schema) => {
+                            single_select_options(schema)
+                        }
+                        _ => Vec::new(),
+                    };
+                    self.render_single_select(
+                        field_name,
+                        value.as_ref(),
+                        options,
+                        error.is_some(),
+                        cx,
+                    )
+                }
+                ElicitationFieldState::MultiSelect(selected) => {
+                    let options = match property {
+                        acp::ElicitationPropertySchema::Array(schema) => {
+                            multi_select_options(schema)
+                        }
+                        _ => Vec::new(),
+                    };
+                    v_flex()
+                        .gap_1()
+                        .children(options.into_iter().map(|option| {
+                            let is_selected = selected.contains(&option.value);
+                            let checkbox_state = if is_selected {
+                                ToggleState::Selected
+                            } else {
+                                ToggleState::Unselected
+                            };
+                            let row_background = Self::option_row_background(is_selected, cx);
+                            let hover_background =
+                                Self::option_row_hover_background(is_selected, cx);
+                            let on_multi_select_change =
+                                self.handlers.on_multi_select_change.clone();
+                            let elicitation_id = self.elicitation.id.clone();
+                            let field_name = field_name.to_string();
+                            let value = option.value.clone();
+                            let checkbox_id = format!(
+                                "elicitation-multi-{}-{field_name}-{}",
+                                self.entry_ix, option.value
+                            );
+                            h_flex()
+                                .id(SharedString::from(format!(
+                                    "elicitation-multi-option-{}-{field_name}-{}",
+                                    self.entry_ix, option.value
+                                )))
+                                .w_full()
+                                .min_h(rems_from_px(28.))
+                                .items_center()
+                                .gap_2()
+                                .rounded_sm()
+                                .border_1()
+                                .border_color(field_border_color.opacity(0.5))
+                                .bg(row_background)
+                                .px_2()
+                                .py_1()
+                                .hover(move |this| this.bg(hover_background).cursor_pointer())
+                                .on_click(move |_, _window, cx| {
+                                    on_multi_select_change(
+                                        elicitation_id.clone(),
+                                        field_name.clone(),
+                                        value.clone(),
+                                        !is_selected,
+                                        cx,
+                                    );
+                                })
+                                .child(Checkbox::new(checkbox_id, checkbox_state))
+                                .child(Label::new(option.label).size(LabelSize::Small).truncate())
+                        }))
+                        .into_any_element()
+                }
+            })
+            .when_some(error.cloned(), |this, error| {
+                this.child(Label::new(error).size(LabelSize::Small).color(Color::Error))
+            })
+            .into_any_element()
+    }
+
+    fn render_single_select(
+        &self,
+        field_name: &str,
+        selected_value: Option<&String>,
+        options: Vec,
+        has_error: bool,
+        cx: &App,
+    ) -> AnyElement {
+        let entry_ix = self.entry_ix;
+        let border_color = if has_error {
+            Color::Error.color(cx)
+        } else {
+            cx.theme().colors().border.opacity(0.8)
+        };
+        let elicitation_id = self.elicitation.id.clone();
+        let field_name = field_name.to_string();
+        let on_single_select_change = self.handlers.on_single_select_change.clone();
+
+        v_flex()
+            .gap_1()
+            .children(options.into_iter().map(move |option| {
+                let option_value = option.value.clone();
+                let option_id =
+                    format!("elicitation-select-option-{entry_ix}-{field_name}-{option_value}");
+                let is_selected =
+                    selected_value.is_some_and(|selected_value| selected_value == &option.value);
+                let row_background = Self::option_row_background(is_selected, cx);
+                let hover_background = Self::option_row_hover_background(is_selected, cx);
+                let control_background = Self::option_control_background(cx);
+                let elicitation_id = elicitation_id.clone();
+                let field_name = field_name.clone();
+                let on_single_select_change = on_single_select_change.clone();
+
+                h_flex()
+                    .id(option_id)
+                    .w_full()
+                    .min_h(rems_from_px(28.))
+                    .items_center()
+                    .gap_2()
+                    .rounded_sm()
+                    .border_1()
+                    .border_color(border_color.opacity(0.5))
+                    .bg(row_background)
+                    .px_2()
+                    .py_1()
+                    .hover(move |this| this.bg(hover_background).cursor_pointer())
+                    .on_click(move |_, _window, cx| {
+                        on_single_select_change(
+                            elicitation_id.clone(),
+                            field_name.clone(),
+                            option_value.clone(),
+                            cx,
+                        );
+                    })
+                    .child(Self::render_radio_indicator(
+                        is_selected,
+                        border_color,
+                        control_background,
+                    ))
+                    .child(Label::new(option.label).size(LabelSize::Small).truncate())
+            }))
+            .into_any_element()
+    }
+
+    fn option_row_background(is_selected: bool, cx: &App) -> Hsla {
+        let editor_background = cx.theme().colors().editor_background;
+        if is_selected {
+            editor_background.blend(Color::Accent.color(cx).opacity(0.08))
+        } else {
+            editor_background
+        }
+    }
+
+    fn option_row_hover_background(is_selected: bool, cx: &App) -> Hsla {
+        let editor_background = cx.theme().colors().editor_background;
+        if is_selected {
+            editor_background.blend(Color::Accent.color(cx).opacity(0.1))
+        } else {
+            cx.theme()
+                .colors()
+                .element_background
+                .blend(cx.theme().colors().editor_foreground.opacity(0.025))
+        }
+    }
+
+    fn option_control_background(cx: &App) -> Hsla {
+        cx.theme().colors().editor_background
+    }
+
+    fn render_radio_indicator(is_selected: bool, border_color: Hsla, background: Hsla) -> Div {
+        div()
+            .size_3()
+            .flex()
+            .items_center()
+            .justify_center()
+            .rounded_full()
+            .border_1()
+            .border_color(border_color)
+            .bg(background)
+            .when(is_selected, |this| {
+                this.child(Indicator::dot().color(Color::Accent))
+            })
+    }
+
+    fn render_url_elicitation(&self, mode: &acp::ElicitationUrlMode) -> AnyElement {
+        v_flex()
+            .gap_2()
+            .child(Self::render_url_summary(&mode.url))
+            .into_any_element()
+    }
+
+    fn render_url_summary(url: &str) -> AnyElement {
+        h_flex()
+            .gap_1()
+            .w_full()
+            .min_w_0()
+            .items_start()
+            .child(
+                div().h(rems_from_px(16.)).flex().items_center().child(
+                    Icon::new(IconName::Link)
+                        .size(IconSize::XSmall)
+                        .color(Color::Muted),
+                ),
+            )
+            .child(h_flex().min_w_0().flex_1().flex_wrap().children(
+                display_url_segments(url).into_iter().map(|segment| {
+                    Label::new(segment)
+                        .size(LabelSize::Small)
+                        .color(Color::Muted)
+                }),
+            ))
+            .into_any_element()
+    }
+
+    fn render_actions(&self, cx: &App) -> AnyElement {
+        let open_url = match &self.elicitation.request.mode {
+            acp::ElicitationMode::Url(mode) => Some(mode.url.clone()),
+            _ => None,
+        };
+        let (accept_label, accept_icon, accept_icon_color) = if open_url.is_some() {
+            ("Open", IconName::ArrowUpRight, Color::Muted)
+        } else {
+            ("Submit", IconName::Check, Color::Success)
+        };
+        let border_color = cx.theme().colors().border.opacity(0.8);
+        let on_submit = self.handlers.on_submit.clone();
+        let on_open_url = self.handlers.on_open_url.clone();
+        let on_decline = self.handlers.on_decline.clone();
+        let on_cancel = self.handlers.on_cancel.clone();
+        let submit_id = self.elicitation.id.clone();
+        let decline_id = self.elicitation.id.clone();
+        let cancel_id = self.elicitation.id.clone();
+
+        h_flex()
+            .w_full()
+            .p_1()
+            .gap_1()
+            .justify_end()
+            .border_t_1()
+            .border_color(border_color)
+            .child(
+                Button::new(("elicitation-accept", self.entry_ix), accept_label)
+                    .start_icon(
+                        Icon::new(accept_icon)
+                            .size(IconSize::XSmall)
+                            .color(accept_icon_color),
+                    )
+                    .label_size(LabelSize::Small)
+                    .on_click(move |_, window, cx| {
+                        if let Some(url) = &open_url {
+                            on_open_url(submit_id.clone(), url.clone(), window, cx);
+                        } else {
+                            on_submit(submit_id.clone(), window, cx);
+                        }
+                    }),
+            )
+            .child(
+                Button::new(("elicitation-decline", self.entry_ix), "Decline")
+                    .start_icon(
+                        Icon::new(IconName::Close)
+                            .size(IconSize::XSmall)
+                            .color(Color::Error),
+                    )
+                    .label_size(LabelSize::Small)
+                    .on_click(move |_, window, cx| {
+                        on_decline(decline_id.clone(), window, cx);
+                    }),
+            )
+            .child(
+                Button::new(("elicitation-cancel", self.entry_ix), "Cancel")
+                    .label_size(LabelSize::Small)
+                    .on_click(move |_, window, cx| {
+                        on_cancel(cancel_id.clone(), window, cx);
+                    }),
+            )
+            .into_any_element()
+    }
+}
diff --git a/crates/agent_ui/src/conversation_view/thread_search_bar.rs b/crates/agent_ui/src/conversation_view/thread_search_bar.rs
index f936edcd35a064..643cceda36a003 100644
--- a/crates/agent_ui/src/conversation_view/thread_search_bar.rs
+++ b/crates/agent_ui/src/conversation_view/thread_search_bar.rs
@@ -946,6 +946,7 @@ fn collect_markdowns(
                 out.push(summary.clone());
             }
         }
+        AgentThreadEntry::Elicitation(_) => {}
         AgentThreadEntry::ContextCompaction(_) => {}
     }
     out
diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs
index 16b6b60875d1c6..8720ff145c02d7 100644
--- a/crates/agent_ui/src/conversation_view/thread_view.rs
+++ b/crates/agent_ui/src/conversation_view/thread_view.rs
@@ -9,8 +9,8 @@ use agent_client_protocol::schema::v1 as acp;
 use std::cell::RefCell;
 
 use acp_thread::{
-    PlanEntry, SandboxAuthorizationDetails, SandboxFallbackAuthorizationDetails,
-    SandboxNotAppliedReason,
+    Elicitation, ElicitationEntryId, ElicitationStatus, PlanEntry, SandboxAuthorizationDetails,
+    SandboxFallbackAuthorizationDetails, SandboxNotAppliedReason,
 };
 use agent::{
     SandboxStatusKey, SandboxStatusRefresh, SkillLoadingIssue, SkillLoadingIssueKind,
@@ -20,6 +20,7 @@ use agent_settings::UserAgentsMd;
 use agent_skills::MAX_SKILL_DESCRIPTION_LEN;
 use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody};
 use editor::actions::OpenExcerpts;
+use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _};
 use sandbox::{GitSandboxPolicy, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy};
 
 use crate::completion_provider::AvailableSkill;
@@ -42,6 +43,9 @@ use ui::{
 };
 use workspace::{OpenOptions, SERIALIZATION_THROTTLE_TIME};
 
+use super::elicitation::{
+    ElicitationCard, ElicitationCardHandlers, ElicitationFormState, should_render_elicitation,
+};
 use super::*;
 
 const DATA_RETENTION_LEARN_MORE_URL: &str = "https://support.claude.com/en/articles/15425996-data-retention-practices-for-mythos-class-models";
@@ -598,6 +602,7 @@ pub struct ThreadView {
     pub new_server_version_available: Option,
     pub resumed_without_history: bool,
     pub(crate) permission_selections: HashMap,
+    elicitation_form_states: HashMap,
     pub _cancel_task: Option>,
     _save_task: Option>,
     _draft_resolve_task: Option>,
@@ -989,6 +994,7 @@ impl ThreadView {
             is_loading_contents: false,
             new_server_version_available: None,
             permission_selections: HashMap::default(),
+            elicitation_form_states: HashMap::default(),
             _cancel_task: None,
             _save_task: None,
             _draft_resolve_task: None,
@@ -1016,6 +1022,7 @@ impl ThreadView {
 
         this.sync_generating_indicator(cx);
         this.sync_editor_mode_for_empty_state(cx);
+        this.sync_existing_elicitation_states(window, cx);
         let list_state_for_scroll = this.list_state.clone();
         let thread_view = cx.entity().downgrade();
 
@@ -2480,15 +2487,182 @@ impl ThreadView {
         Some(())
     }
 
-    fn is_waiting_for_confirmation(entry: &AgentThreadEntry) -> bool {
-        if let AgentThreadEntry::ToolCall(tool_call) = entry {
-            matches!(
-                tool_call.status,
-                ToolCallStatus::WaitingForConfirmation { .. }
+    fn is_waiting_for_confirmation(&self, entry: &AgentThreadEntry, cx: &Context) -> bool {
+        match entry {
+            AgentThreadEntry::ToolCall(tool_call) => {
+                matches!(
+                    tool_call.status,
+                    ToolCallStatus::WaitingForConfirmation { .. }
+                )
+            }
+            AgentThreadEntry::Elicitation(elicitation_id) => {
+                cx.has_flag::()
+                    && self
+                        .thread
+                        .read(cx)
+                        .elicitation(elicitation_id)
+                        .is_some_and(|(_, elicitation)| {
+                            matches!(elicitation.status, ElicitationStatus::Pending { .. })
+                        })
+            }
+            _ => false,
+        }
+    }
+
+    pub fn sync_elicitation_state_for_entry(
+        &mut self,
+        index: usize,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
+        let elicitation_id = {
+            let thread = self.thread.read(cx);
+            let Some(AgentThreadEntry::Elicitation(elicitation_id)) = thread.entries().get(index)
+            else {
+                return;
+            };
+            elicitation_id.clone()
+        };
+
+        if !cx.has_flag::() {
+            self.elicitation_form_states.remove(&elicitation_id);
+            return;
+        }
+
+        let thread = self.thread.read(cx);
+        let entry = thread.elicitation(&elicitation_id).map(|(_, elicitation)| {
+            (
+                elicitation_id.clone(),
+                matches!(elicitation.status, ElicitationStatus::Pending { .. }),
+                match &elicitation.request.mode {
+                    acp::ElicitationMode::Form(mode) => Some(mode.requested_schema.clone()),
+                    _ => None,
+                },
             )
-        } else {
-            false
+        });
+
+        let Some((id, is_pending, schema)) = entry else {
+            return;
+        };
+
+        if is_pending
+            && let Some(schema) = schema
+            && !self.elicitation_form_states.contains_key(&id)
+        {
+            self.elicitation_form_states
+                .insert(id, ElicitationFormState::new(&schema, window, cx));
+        } else if !is_pending {
+            self.elicitation_form_states.remove(&id);
+        }
+    }
+
+    fn sync_existing_elicitation_states(&mut self, window: &mut Window, cx: &mut Context) {
+        let entry_count = self.thread.read(cx).entries().len();
+        for index in 0..entry_count {
+            self.sync_elicitation_state_for_entry(index, window, cx);
+        }
+    }
+
+    #[cfg(test)]
+    pub(crate) fn has_elicitation_form_state(&self, id: &ElicitationEntryId) -> bool {
+        self.elicitation_form_states.contains_key(id)
+    }
+
+    fn submit_elicitation(
+        &mut self,
+        elicitation_id: ElicitationEntryId,
+        _window: &mut Window,
+        cx: &mut Context,
+    ) {
+        if !cx.has_flag::() {
+            return;
+        }
+
+        let mode = self
+            .thread
+            .read(cx)
+            .elicitation(&elicitation_id)
+            .map(|(_, elicitation)| elicitation.request.mode.clone());
+
+        let Some(mode) = mode else {
+            return;
+        };
+
+        let response = match mode {
+            acp::ElicitationMode::Form(mode) => {
+                let Some(state) = self.elicitation_form_states.get(&elicitation_id) else {
+                    return;
+                };
+                match state.collect(&mode.requested_schema, cx) {
+                    Ok(content) => {
+                        acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
+                            acp::ElicitationAcceptAction::new().content(content),
+                        ))
+                    }
+                    Err(errors) => {
+                        if let Some(state) = self.elicitation_form_states.get_mut(&elicitation_id) {
+                            state.set_errors(errors);
+                        }
+                        cx.notify();
+                        return;
+                    }
+                }
+            }
+            acp::ElicitationMode::Url(_) => acp::CreateElicitationResponse::new(
+                acp::ElicitationAction::Accept(acp::ElicitationAcceptAction::new()),
+            ),
+            _ => return,
+        };
+
+        self.respond_to_elicitation(elicitation_id, response, cx);
+    }
+
+    fn decline_elicitation(
+        &mut self,
+        elicitation_id: ElicitationEntryId,
+        _window: &mut Window,
+        cx: &mut Context,
+    ) {
+        if !cx.has_flag::() {
+            return;
+        }
+
+        self.respond_to_elicitation(
+            elicitation_id,
+            acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline),
+            cx,
+        );
+    }
+
+    fn cancel_elicitation(
+        &mut self,
+        elicitation_id: ElicitationEntryId,
+        _window: &mut Window,
+        cx: &mut Context,
+    ) {
+        if !cx.has_flag::() {
+            return;
         }
+
+        self.respond_to_elicitation(
+            elicitation_id,
+            acp::CreateElicitationResponse::new(acp::ElicitationAction::Cancel),
+            cx,
+        );
+    }
+
+    fn respond_to_elicitation(
+        &mut self,
+        elicitation_id: ElicitationEntryId,
+        response: acp::CreateElicitationResponse,
+        cx: &mut Context,
+    ) {
+        let session_id = self.session_id.clone();
+        self.elicitation_form_states.remove(&elicitation_id);
+        self.conversation.update(cx, |conversation, cx| {
+            conversation.respond_to_elicitation(session_id, elicitation_id, response, cx);
+        });
+        cx.notify();
     }
 
     fn handle_authorize_tool_call(
@@ -5859,7 +6033,7 @@ impl ThreadView {
                 } else if this.generating_indicator_in_list {
                     let confirmation = entries
                         .last()
-                        .is_some_and(|entry| Self::is_waiting_for_confirmation(entry));
+                        .is_some_and(|entry| this.is_waiting_for_confirmation(entry, cx));
                     let rendered = this.render_generating(confirmation, cx);
                     centered_container(rendered.into_any_element()).into_any_element()
                 } else {
@@ -6178,6 +6352,28 @@ impl ThreadView {
                     tool_call.into_any()
                 }
             }
+            AgentThreadEntry::Elicitation(elicitation_id) => {
+                let thread = self.thread.read(cx);
+                if cx.has_flag::()
+                    && let Some((_, elicitation)) = thread.elicitation(elicitation_id)
+                    && should_render_elicitation(elicitation)
+                {
+                    let elicitation = self.render_elicitation(entry_ix, elicitation, window, cx);
+
+                    if let Some(handle) = self
+                        .entry_view_state
+                        .read(cx)
+                        .entry(entry_ix)
+                        .and_then(|entry| entry.focus_handle(cx))
+                    {
+                        elicitation.track_focus(&handle).into_any()
+                    } else {
+                        elicitation.into_any()
+                    }
+                } else {
+                    Empty.into_any()
+                }
+            }
             AgentThreadEntry::CompletedPlan(entries) => {
                 self.render_completed_plan(entries, window, cx)
             }
@@ -6251,7 +6447,7 @@ impl ThreadView {
             primary
         };
 
-        let needs_confirmation = Self::is_waiting_for_confirmation(entry);
+        let needs_confirmation = self.is_waiting_for_confirmation(entry, cx);
 
         let comments_editor = self.thread_feedback.comments_editor.clone();
 
@@ -6295,6 +6491,99 @@ impl ThreadView {
         }
     }
 
+    fn render_elicitation(
+        &self,
+        entry_ix: usize,
+        elicitation: &Elicitation,
+        _window: &Window,
+        cx: &Context,
+    ) -> Div {
+        ElicitationCard::new(
+            entry_ix,
+            elicitation,
+            self.elicitation_form_states.get(&elicitation.id),
+            self.elicitation_card_handlers(cx),
+        )
+        .render(cx)
+    }
+
+    fn elicitation_card_handlers(&self, cx: &Context) -> ElicitationCardHandlers {
+        let view = cx.entity().downgrade();
+
+        ElicitationCardHandlers::new(
+            {
+                let view = view.clone();
+                move |elicitation_id, window, cx| {
+                    view.update(cx, |this, cx| {
+                        this.submit_elicitation(elicitation_id, window, cx);
+                    })
+                    .log_err();
+                }
+            },
+            {
+                let view = view.clone();
+                move |elicitation_id, window, cx| {
+                    view.update(cx, |this, cx| {
+                        this.decline_elicitation(elicitation_id, window, cx);
+                    })
+                    .log_err();
+                }
+            },
+            {
+                let view = view.clone();
+                move |elicitation_id, window, cx| {
+                    view.update(cx, |this, cx| {
+                        this.cancel_elicitation(elicitation_id, window, cx);
+                    })
+                    .log_err();
+                }
+            },
+            {
+                let view = view.clone();
+                move |elicitation_id, url, window, cx| {
+                    cx.open_url(&url);
+                    view.update(cx, |this, cx| {
+                        this.submit_elicitation(elicitation_id, window, cx);
+                    })
+                    .log_err();
+                }
+            },
+            {
+                let view = view.clone();
+                move |elicitation_id, field_name, value, cx| {
+                    view.update(cx, |this, cx| {
+                        if let Some(form) = this.elicitation_form_states.get_mut(&elicitation_id) {
+                            form.set_boolean(&field_name, value);
+                            cx.notify();
+                        }
+                    })
+                    .log_err();
+                }
+            },
+            {
+                let view = view.clone();
+                move |elicitation_id, field_name, value, cx| {
+                    view.update(cx, |this, cx| {
+                        if let Some(form) = this.elicitation_form_states.get_mut(&elicitation_id) {
+                            form.set_single_select(&field_name, value);
+                            cx.notify();
+                        }
+                    })
+                    .log_err();
+                }
+            },
+            move |elicitation_id, field_name, value, selected, cx| {
+                view.update(cx, |this, cx| {
+                    if let Some(form) = this.elicitation_form_states.get_mut(&elicitation_id) {
+                        form.set_multi_select(&field_name, value, selected);
+                        cx.notify();
+                    }
+                })
+                .log_err();
+            },
+        )
+    }
+
     fn render_feedback_feedback_editor(editor: Entity, cx: &Context) -> Div {
         h_flex()
             .key_context("AgentFeedbackMessageEditor")
@@ -6506,6 +6795,19 @@ impl ThreadView {
             .into_any_element()
     }
 
+    fn render_request_elicitations(&self, cx: &Context) -> Vec {
+        let server_view = self.server_view.clone();
+        let handlers_view = server_view.clone();
+        server_view
+            .read_with(cx, |server_view, cx| {
+                let Some(connection) = server_view.request_elicitation_connection() else {
+                    return Vec::new();
+                };
+                server_view.render_request_elicitations(&connection, handlers_view, cx)
+            })
+            .unwrap_or_default()
+    }
+
     pub(crate) fn scroll_to_most_recent_user_prompt(&mut self, cx: &mut Context) {
         let entries = self.thread.read(cx).entries();
         if entries.is_empty() {
@@ -7241,6 +7543,7 @@ impl ThreadView {
                     }
                 }
                 AgentThreadEntry::ToolCall(_)
+                | AgentThreadEntry::Elicitation(_)
                 | AgentThreadEntry::AssistantMessage(_)
                 | AgentThreadEntry::CompletedPlan(_)
                 | AgentThreadEntry::ContextCompaction(_) => {}
@@ -11642,6 +11945,7 @@ impl Render for ThreadView {
                 |this, version| this.child(self.render_new_version_callout(&version, cx)),
             )
             .children(self.render_token_limit_callout(cx))
+            .children(self.render_request_elicitations(cx))
             .child(self.render_message_editor(window, cx))
     }
 }
diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs
index d19a1067baeeea..e2ad7ca71ecb34 100644
--- a/crates/agent_ui/src/entry_view_state.rs
+++ b/crates/agent_ui/src/entry_view_state.rs
@@ -377,6 +377,16 @@ impl EntryViewState {
                     });
                 }
             }
+            AgentThreadEntry::Elicitation(_) => {
+                if !matches!(self.entries.get(index), Some(Entry::Elicitation { .. })) {
+                    self.set_entry(
+                        index,
+                        Entry::Elicitation {
+                            focus_handle: cx.focus_handle(),
+                        },
+                    );
+                }
+            }
             AgentThreadEntry::AssistantMessage(message) => {
                 let entry = if let Some(Entry::AssistantMessage(entry)) =
                     self.entries.get_mut(index)
@@ -452,6 +462,7 @@ impl EntryViewState {
             match entry {
                 Entry::UserMessage { .. }
                 | Entry::AssistantMessage { .. }
+                | Entry::Elicitation { .. }
                 | Entry::CompletedPlan
                 | Entry::ContextCompaction => {}
                 Entry::ToolCall(ToolCallEntry { content, .. }) => {
@@ -521,6 +532,7 @@ pub enum Entry {
     UserMessage(Entity),
     AssistantMessage(AssistantMessageEntry),
     ToolCall(ToolCallEntry),
+    Elicitation { focus_handle: FocusHandle },
     CompletedPlan,
     ContextCompaction,
 }
@@ -531,6 +543,7 @@ impl Entry {
             Self::UserMessage(editor) => Some(editor.read(cx).focus_handle(cx)),
             Self::AssistantMessage(message) => Some(message.focus_handle.clone()),
             Self::ToolCall(tool_call) => Some(tool_call.focus_handle.clone()),
+            Self::Elicitation { focus_handle } => Some(focus_handle.clone()),
             Self::CompletedPlan | Self::ContextCompaction => None,
         }
     }
@@ -540,6 +553,7 @@ impl Entry {
             Self::UserMessage(editor) => Some(editor),
             Self::AssistantMessage(_)
             | Self::ToolCall(_)
+            | Self::Elicitation { .. }
             | Self::CompletedPlan
             | Self::ContextCompaction => None,
         }
@@ -570,6 +584,7 @@ impl Entry {
             Self::AssistantMessage(message) => message.scroll_handle_for_chunk(chunk_ix),
             Self::UserMessage(_)
             | Self::ToolCall(_)
+            | Self::Elicitation { .. }
             | Self::CompletedPlan
             | Self::ContextCompaction => None,
         }
@@ -588,6 +603,7 @@ impl Entry {
             Self::ToolCall(ToolCallEntry { content, .. }) => !content.is_empty(),
             Self::UserMessage(_)
             | Self::AssistantMessage(_)
+            | Self::Elicitation { .. }
             | Self::CompletedPlan
             | Self::ContextCompaction => false,
         }
@@ -606,6 +622,7 @@ impl Focusable for Entry {
             Self::UserMessage(editor) => editor.read(cx).focus_handle(cx),
             Self::AssistantMessage(message) => message.focus_handle.clone(),
             Self::ToolCall(tool_call) => tool_call.focus_handle.clone(),
+            Self::Elicitation { focus_handle } => focus_handle.clone(),
             Self::CompletedPlan | Self::ContextCompaction => cx.focus_handle(),
         }
     }
@@ -693,11 +710,12 @@ mod tests {
     use agent_client_protocol::schema::v1 as acp;
     use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind};
     use editor::RowInfo;
+    use feature_flags::{AcpBetaFeatureFlag, FeatureFlag as _, FeatureFlagAppExt as _};
     use fs::FakeFs;
     use gpui::{AppContext as _, TestAppContext};
     use parking_lot::RwLock;
 
-    use crate::entry_view_state::EntryViewState;
+    use crate::entry_view_state::{Entry, EntryViewState};
     use crate::message_editor::SessionCapabilities;
     use multi_buffer::MultiBufferRow;
     use pretty_assertions::assert_matches;
@@ -829,9 +847,90 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_hidden_elicitation_preserves_entry_index(cx: &mut TestAppContext) {
+        init_test(cx);
+        cx.update(|cx| {
+            cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]);
+        });
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree("/project", json!({})).await;
+        let project = Project::test(fs, [Path::new(path!("/project"))], 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 connection = Rc::new(StubAgentConnection::new());
+        let thread = cx
+            .update(|_, cx| {
+                connection.clone().new_session(
+                    project.clone(),
+                    PathList::new(&[Path::new(path!("/project"))]),
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+        let session_id = thread.update(cx, |thread, _| thread.session_id().clone());
+
+        let _response_task = thread.update(cx, |thread, cx| {
+            thread
+                .request_elicitation(
+                    acp::CreateElicitationRequest::new(
+                        acp::ElicitationFormMode::new(
+                            acp::ElicitationSessionScope::new(session_id.clone()),
+                            acp::ElicitationSchema::new().string("name", true),
+                        ),
+                        "Provide a name",
+                    ),
+                    cx,
+                )
+                .unwrap()
+        });
+        cx.update(|_, cx| {
+            connection.send_update(
+                session_id,
+                acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
+                    acp::ContentBlock::Text(acp::TextContent::new("hello")),
+                )),
+                cx,
+            );
+            cx.update_flags(false, vec![]);
+        });
+
+        let view_state = cx.new(|_cx| {
+            EntryViewState::new(
+                workspace.downgrade(),
+                project.downgrade(),
+                None,
+                Arc::new(RwLock::new(SessionCapabilities::default())),
+                "Test Agent".into(),
+            )
+        });
+
+        view_state.update_in(cx, |view_state, window, cx| {
+            view_state.sync_entry(0, &thread, window, cx);
+            view_state.sync_entry(1, &thread, window, cx);
+        });
+
+        view_state.read_with(cx, |view_state, _cx| {
+            assert!(matches!(
+                view_state.entry(0),
+                Some(Entry::Elicitation { .. })
+            ));
+            assert!(matches!(
+                view_state.entry(1),
+                Some(Entry::AssistantMessage(_))
+            ));
+        });
+    }
+
     fn init_test(cx: &mut TestAppContext) {
         cx.update(|cx| {
-            let settings_store = SettingsStore::test(cx);
+            let mut settings_store = SettingsStore::test(cx);
+            settings_store.register_setting::();
             cx.set_global(settings_store);
             theme_settings::init(theme::LoadThemes::JustBase, cx);
             release_channel::init(semver::Version::new(0, 0, 0), cx);

From 15c31d4147fe8c5d12c1844bbba19d300ec2840e Mon Sep 17 00:00:00 2001
From: Bennet Bo Fenner 
Date: Wed, 1 Jul 2026 16:31:46 +0200
Subject: [PATCH 014/422] settings_ui: Add OpenAI/Anthropic API-compatible
 provider form (#60195)

Release Notes:

- N/A

---------

Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
Co-authored-by: Danilo Leal 
---
 .../src/provider/anthropic_compatible.rs      |    5 +
 .../src/provider/api_compatible.rs            |   38 +-
 .../src/provider/open_ai_compatible.rs        |    5 +
 crates/settings_ui/src/pages.rs               |    4 +-
 .../src/pages/llm_providers_page.rs           | 1023 ++++++++++++++++-
 crates/settings_ui/src/settings_ui.rs         |   25 +-
 6 files changed, 1044 insertions(+), 56 deletions(-)

diff --git a/crates/language_models/src/provider/anthropic_compatible.rs b/crates/language_models/src/provider/anthropic_compatible.rs
index 38f31d2d7a83f5..b07900453243d7 100644
--- a/crates/language_models/src/provider/anthropic_compatible.rs
+++ b/crates/language_models/src/provider/anthropic_compatible.rs
@@ -199,6 +199,11 @@ impl LanguageModelProvider for AnthropicCompatibleLanguageModelProvider {
         .into()
     }
 
+    fn set_api_key(&self, key: String, 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))
diff --git a/crates/language_models/src/provider/api_compatible.rs b/crates/language_models/src/provider/api_compatible.rs
index e186f0baf956d0..f8b4e731c8f24d 100644
--- a/crates/language_models/src/provider/api_compatible.rs
+++ b/crates/language_models/src/provider/api_compatible.rs
@@ -178,6 +178,23 @@ impl ApiCompatibleProviderConfigurationView
         .detach_and_log_err(cx);
     }
 
+    fn remove_provider(&mut self, _: &mut Window, cx: &mut Context) {
+        let id = self.state.read(cx).id.clone();
+        let fs = ::global(cx);
+        settings::update_settings_file(fs, cx, move |settings, _| {
+            let Some(language_models) = settings.language_models.as_mut() else {
+                return;
+            };
+
+            if let Some(providers) = language_models.openai_compatible.as_mut() {
+                providers.remove(id.as_ref());
+            }
+            if let Some(providers) = language_models.anthropic_compatible.as_mut() {
+                providers.remove(id.as_ref());
+            }
+        });
+    }
+
     fn should_render_editor(&self, cx: &Context) -> bool {
         !self.state.read(cx).is_authenticated()
     }
@@ -256,7 +273,26 @@ impl Render for ApiCompatibleProviderConfigura
         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()
+            v_flex()
+                .size_full()
+                .gap_4()
+                .child(api_key_section)
+                .child(
+                    h_flex().w_full().justify_end().child(
+                        Button::new("remove-compatible-provider", "Remove Provider")
+                            .style(ButtonStyle::OutlinedGhost)
+                            .label_size(LabelSize::Small)
+                            .start_icon(
+                                Icon::new(IconName::Trash)
+                                    .size(IconSize::Small)
+                                    .color(Color::Muted),
+                            )
+                            .on_click(cx.listener(|this, _event, window, cx| {
+                                this.remove_provider(window, cx)
+                            })),
+                    ),
+                )
+                .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 847978b7955bfd..e0c6f1f16ea1fb 100644
--- a/crates/language_models/src/provider/open_ai_compatible.rs
+++ b/crates/language_models/src/provider/open_ai_compatible.rs
@@ -162,6 +162,11 @@ impl LanguageModelProvider for OpenAiCompatibleLanguageModelProvider {
         .into()
     }
 
+    fn set_api_key(&self, key: String, 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))
diff --git a/crates/settings_ui/src/pages.rs b/crates/settings_ui/src/pages.rs
index 407343365ea711..6038a5eaa16929 100644
--- a/crates/settings_ui/src/pages.rs
+++ b/crates/settings_ui/src/pages.rs
@@ -19,7 +19,9 @@ pub(crate) use external_agents_page::{
     CustomAgentForm, render_add_agent_popover, render_external_agents_page,
 };
 pub(crate) use feature_flags::render_feature_flags_page;
-pub(crate) use llm_providers_page::render_llm_providers_page;
+pub(crate) use llm_providers_page::{
+    LlmProviderForm, render_add_llm_provider_popover, render_llm_providers_page,
+};
 pub(crate) use mcp_servers_page::{
     McpServerForm, render_add_server_popover, render_mcp_servers_page,
 };
diff --git a/crates/settings_ui/src/pages/llm_providers_page.rs b/crates/settings_ui/src/pages/llm_providers_page.rs
index cb0b0a95b994a0..f888b031eb8f1b 100644
--- a/crates/settings_ui/src/pages/llm_providers_page.rs
+++ b/crates/settings_ui/src/pages/llm_providers_page.rs
@@ -1,12 +1,23 @@
-use std::sync::Arc;
+use std::{collections::HashSet, sync::Arc};
 
-use gpui::{AnyView, ScrollHandle, prelude::*};
+use editor::Editor;
+use gpui::{AnyView, Entity, Focusable as _, ScrollHandle, prelude::*};
 use language_model::{
     ApiKeyConfiguration, ConfigurationViewTargetAgent, IconOrSvg, InlineDescription,
     LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry,
     ProviderConfigurationView,
 };
-use ui::{ButtonLink, ConfiguredApiCard, Divider, DividerColor, prelude::*};
+
+use settings::{
+    AnthropicCompatibleAvailableModel, AnthropicCompatibleModelCapabilities,
+    AnthropicCompatibleSettingsContent, OpenAiCompatibleAvailableModel,
+    OpenAiCompatibleModelCapabilities, OpenAiCompatibleSettingsContent, OpenAiReasoningEffort,
+};
+use ui::{
+    ButtonLink, Checkbox, ConfiguredApiCard, ContextMenu, Divider, DividerColor, DropdownMenu,
+    DropdownStyle, IconPosition, PopoverMenu, ToggleState, prelude::*,
+};
+use util::ResultExt as _;
 
 use crate::SettingsWindow;
 use crate::components::SettingsInputField;
@@ -38,6 +49,96 @@ pub(crate) fn render_llm_providers_page(
         .into_any_element()
 }
 
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(crate) enum CompatibleProviderKind {
+    OpenAi,
+    Anthropic,
+}
+
+impl CompatibleProviderKind {
+    fn label(self) -> &'static str {
+        match self {
+            Self::OpenAi => "OpenAI",
+            Self::Anthropic => "Anthropic",
+        }
+    }
+
+    fn default_api_url(self) -> &'static str {
+        match self {
+            Self::OpenAi => "https://api.openai.com/v1",
+            Self::Anthropic => "https://api.anthropic.com",
+        }
+    }
+}
+
+pub(crate) fn render_add_llm_provider_popover(
+    settings_window: &SettingsWindow,
+    _window: &mut Window,
+    cx: &mut Context,
+) -> impl IntoElement {
+    let focus_handle = settings_window
+        .llm_provider_add_focus_handle
+        .clone()
+        .tab_index(0)
+        .tab_stop(true);
+
+    let settings_window = cx.entity().downgrade();
+
+    PopoverMenu::new("add-llm-provider-popover")
+        .trigger(
+            Button::new("add-llm-provider", "Add Provider")
+                .style(ButtonStyle::Outlined)
+                .track_focus(&focus_handle)
+                .label_size(LabelSize::Small)
+                .start_icon(
+                    Icon::new(IconName::Plus)
+                        .size(IconSize::Small)
+                        .color(Color::Muted),
+                ),
+        )
+        .anchor(gpui::Anchor::TopRight)
+        .offset(gpui::Point {
+            x: px(0.0),
+            y: px(2.0),
+        })
+        .menu(move |window, cx| {
+            let settings_window = settings_window.clone();
+            Some(ContextMenu::build(window, cx, move |menu, _window, _cx| {
+                menu.header("Compatible APIs")
+                    .entry("OpenAI", None, {
+                        let settings_window = settings_window.clone();
+                        move |window, cx| {
+                            settings_window
+                                .update(cx, |this, cx| {
+                                    open_llm_provider_form(
+                                        this,
+                                        CompatibleProviderKind::OpenAi,
+                                        window,
+                                        cx,
+                                    );
+                                })
+                                .log_err();
+                        }
+                    })
+                    .entry("Anthropic", None, {
+                        let settings_window = settings_window;
+                        move |window, cx| {
+                            settings_window
+                                .update(cx, |this, cx| {
+                                    open_llm_provider_form(
+                                        this,
+                                        CompatibleProviderKind::Anthropic,
+                                        window,
+                                        cx,
+                                    );
+                                })
+                                .log_err();
+                        }
+                    })
+            }))
+        })
+}
+
 fn render_provider_section(
     settings_window: &SettingsWindow,
     provider: &Arc,
@@ -49,7 +150,7 @@ fn render_provider_section(
     let provider_name = provider.name().0;
 
     let body = if let Some(config) = provider.api_key_configuration(cx) {
-        render_api_key_providers_item(provider, provider_name.clone(), config)
+        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)
         {
@@ -95,9 +196,11 @@ 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,
@@ -113,83 +216,95 @@ fn render_api_key_providers_item(
             "API Key Configured"
         };
         let button_id = format!("reset-api-key-{}", provider.id().0);
-        let provider = provider.clone();
-        let env_var_name_for_tooltip = env_var_name;
 
-        return ConfiguredApiCard::new(button_id, configured_label)
+        let card = ConfiguredApiCard::new(button_id, configured_label)
             .button_label("Reset Key")
             .button_tab_index(0)
             .disabled(is_from_env_var)
             .when(is_from_env_var, |this| {
                 this.tooltip_label(format!(
-                    "To reset your API key, unset the {env_var_name_for_tooltip} environment variable."
+                    "To reset your API key, unset the {env_var_name} environment variable."
                 ))
             })
-            .on_click(move |_, _, cx| {
-                provider.reset_credentials(cx).detach_and_log_err(cx);
+            .on_click({
+                let provider = provider.clone();
+                move |_, _, cx| {
+                    provider.reset_credentials(cx).detach_and_log_err(cx);
+                }
             })
             .into_any_element();
+
+        return v_flex().gap_2().child(card).into_any_element();
     }
 
     let input_id = format!("{}-api-key-input", provider.id().0);
     let aria_label = format!("{provider_name} API Key");
-    let provider = provider.clone();
 
-    h_flex()
-        .pt_2p5()
-        .w_full()
-        .min_w_0()
-        .gap_4()
-        .justify_between()
+    v_flex()
+        .gap_2()
         .child(
-            v_flex()
+            h_flex()
+                .pt_2p5()
                 .w_full()
                 .min_w_0()
-                .max_w_1_2()
-                .gap_0p5()
-                .child(Label::new("API Key"))
+                .gap_4()
+                .justify_between()
                 .child(
-                    h_flex()
+                    v_flex()
                         .w_full()
                         .min_w_0()
-                        .flex_wrap()
+                        .max_w_1_2()
                         .gap_0p5()
+                        .child(Label::new("API Key"))
                         .child(
-                            Label::new("Visit the")
-                                .size(LabelSize::Small)
-                                .color(Color::Muted),
-                        )
-                        .child(
-                            ButtonLink::new(format!("{provider_name} dashboard"), api_key_url)
-                                .no_icon(true)
-                                .label_size(LabelSize::Small)
-                                .label_color(Color::Muted),
+                            h_flex()
+                                .w_full()
+                                .min_w_0()
+                                .flex_wrap()
+                                .gap_0p5()
+                                .child(
+                                    Label::new("Visit the")
+                                        .size(LabelSize::Small)
+                                        .color(Color::Muted),
+                                )
+                                .child(
+                                    ButtonLink::new(
+                                        format!("{provider_name} dashboard"),
+                                        api_key_url,
+                                    )
+                                    .no_icon(true)
+                                    .label_size(LabelSize::Small)
+                                    .label_color(Color::Muted),
+                                )
+                                .child(
+                                    Label::new("to generate an API key.")
+                                        .size(LabelSize::Small)
+                                        .color(Color::Muted),
+                                ),
                         )
                         .child(
-                            Label::new("to generate an API key.")
-                                .size(LabelSize::Small)
-                                .color(Color::Muted),
+                            Label::new(format!(
+                                "Or set the {env_var_name} env var and restart Zed for it to take effect."
+                            ))
+                            .size(LabelSize::XSmall)
+                            .color(Color::Muted),
                         ),
                 )
                 .child(
-                    Label::new(format!(
-                        "Or set the {env_var_name} env var and restart Zed for it to take effect."
-                    ))
-                    .size(LabelSize::XSmall)
-                    .color(Color::Muted),
+                    SettingsInputField::new(input_id)
+                        .tab_index(0)
+                        .with_placeholder("xxxxxxxxxxxxxxxxxxxx")
+                        .aria_label(aria_label)
+                        .on_confirm({
+                            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);
+                                }
+                            }
+                        }),
                 ),
         )
-        .child(
-            SettingsInputField::new(input_id)
-                .tab_index(0)
-                .with_placeholder("xxxxxxxxxxxxxxxxxxxx")
-                .aria_label(aria_label)
-                .on_confirm(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);
-                    }
-                }),
-        )
         .into_any_element()
 }
 
@@ -314,7 +429,7 @@ fn open_provider_configuration(
         title,
         "Agent Configuration",
         Some("llm_providers"),
-        false,
+        true,
         render_provider_config_sub_page,
         window,
         cx,
@@ -385,3 +500,807 @@ fn get_or_create_configuration_view(
 
     view
 }
+
+pub(crate) struct LlmProviderForm {
+    kind: CompatibleProviderKind,
+    provider_name: Entity,
+    api_url: Entity,
+    api_key: Entity,
+    models: Vec,
+    error: Option,
+}
+
+impl LlmProviderForm {
+    fn new(
+        kind: CompatibleProviderKind,
+        window: &mut Window,
+        cx: &mut Context,
+    ) -> Self {
+        Self {
+            kind,
+            provider_name: new_input(kind.label(), None, false, window, cx),
+            api_url: new_input(kind.default_api_url(), None, false, window, cx),
+            api_key: new_input(
+                "000000000000000000000000000000000000000000000000",
+                None,
+                true,
+                window,
+                cx,
+            ),
+            models: vec![ModelInput::new(0, window, cx)],
+            error: None,
+        }
+    }
+}
+
+struct ModelInput {
+    name: Entity,
+    max_completion_tokens: Entity,
+    max_output_tokens: Entity,
+    max_tokens: Entity,
+    reasoning_effort: OpenAiReasoningEffort,
+    supports_tools: ToggleState,
+    supports_images: ToggleState,
+    supports_parallel_tool_calls: ToggleState,
+    supports_prompt_cache_key: ToggleState,
+    supports_chat_completions: ToggleState,
+    supports_thinking: ToggleState,
+    interleaved_reasoning: ToggleState,
+    max_tokens_parameter: ToggleState,
+}
+
+impl ModelInput {
+    fn new(_index: usize, window: &mut Window, cx: &mut Context) -> Self {
+        let OpenAiCompatibleModelCapabilities {
+            tools,
+            images,
+            parallel_tool_calls,
+            prompt_cache_key,
+            chat_completions,
+            interleaved_reasoning,
+            max_tokens_parameter,
+        } = OpenAiCompatibleModelCapabilities::default();
+
+        Self {
+            name: new_input(
+                "e.g. gpt-5, claude-opus-4, gemini-2.5-pro",
+                None,
+                false,
+                window,
+                cx,
+            ),
+            max_completion_tokens: new_input("200000", Some("200000"), false, window, cx),
+            max_output_tokens: new_input("Max Output Tokens", Some("32000"), false, window, cx),
+            max_tokens: new_input("Max Tokens", Some("200000"), false, window, cx),
+            reasoning_effort: OpenAiReasoningEffort::Medium,
+            supports_tools: tools.into(),
+            supports_images: images.into(),
+            supports_parallel_tool_calls: parallel_tool_calls.into(),
+            supports_prompt_cache_key: prompt_cache_key.into(),
+            supports_chat_completions: chat_completions.into(),
+            supports_thinking: ToggleState::Unselected,
+            interleaved_reasoning: interleaved_reasoning.into(),
+            max_tokens_parameter: max_tokens_parameter.into(),
+        }
+    }
+}
+
+fn new_input(
+    placeholder: &str,
+    initial: Option<&str>,
+    masked: bool,
+    window: &mut Window,
+    cx: &mut Context,
+) -> Entity {
+    let placeholder = placeholder.to_string();
+    let initial = initial.map(str::to_string);
+    cx.new(|cx| {
+        let mut editor = Editor::single_line(window, cx);
+        editor.set_placeholder_text(placeholder.as_str(), window, cx);
+        editor.set_masked(masked, cx);
+        if let Some(text) = initial {
+            editor.set_text(text, window, cx);
+        }
+        editor
+    })
+}
+
+fn open_llm_provider_form(
+    settings_window: &mut SettingsWindow,
+    kind: CompatibleProviderKind,
+    window: &mut Window,
+    cx: &mut Context,
+) {
+    settings_window.llm_provider_form = Some(LlmProviderForm::new(kind, window, cx));
+    settings_window.push_dynamic_sub_page(
+        format!("Add {}-Compatible Provider", kind.label()),
+        "Agent Configuration",
+        Some("llm_providers"),
+        true,
+        render_llm_provider_form_page,
+        window,
+        cx,
+    );
+}
+
+fn render_llm_provider_form_page(
+    settings_window: &SettingsWindow,
+    scroll_handle: &ScrollHandle,
+    window: &mut Window,
+    cx: &mut Context,
+) -> AnyElement {
+    let Some(form) = settings_window.llm_provider_form.as_ref() else {
+        return div().into_any_element();
+    };
+
+    v_flex()
+        .size_full()
+        .child(
+            v_flex()
+                .id("llm-provider-form-page")
+                .track_scroll(scroll_handle)
+                .pt_2p5()
+                .px_8()
+                .pb_16()
+                .gap_4()
+                .overflow_y_scroll()
+                .child(Label::new(match form.kind {
+                    CompatibleProviderKind::OpenAi => {
+                        "This provider will use an OpenAI-compatible API."
+                    }
+                    CompatibleProviderKind::Anthropic => {
+                        "This provider will use an Anthropic Messages-compatible API."
+                    }
+                }))
+                .child(Divider::horizontal().flex_shrink_0())
+                .child(render_form_field(
+                    "Provider Name",
+                    "A unique name used to identify this provider.",
+                    &form.provider_name,
+                    cx,
+                ))
+                .child(render_form_field(
+                    "API URL",
+                    "The base URL for the compatible API.",
+                    &form.api_url,
+                    cx,
+                ))
+                .child(render_form_field(
+                    "API Key",
+                    "Stored in the system keychain, not in settings.json.",
+                    &form.api_key,
+                    cx,
+                ))
+                .child(render_models_section(form, window, cx)),
+        )
+        .child(
+            v_flex()
+                .px_8()
+                .py_2p5()
+                .gap_1()
+                .border_t_1()
+                .border_color(cx.theme().colors().border_variant)
+                .when_some(form.error.clone(), |this, error| {
+                    this.child(render_form_error(error))
+                })
+                .child(render_form_actions(cx)),
+        )
+        .into_any_element()
+}
+
+fn render_form_field(
+    title: &'static str,
+    description: &'static str,
+    editor: &Entity,
+    cx: &mut Context,
+) -> AnyElement {
+    let colors = cx.theme().colors();
+    let focus_handle = editor.focus_handle(cx).tab_index(0).tab_stop(true);
+    v_flex()
+        .w_full()
+        .gap_1p5()
+        .child(
+            v_flex()
+                .gap_0p5()
+                .child(
+                    h_flex().gap_0p5().child(Label::new(title)).child(
+                        Label::new("*")
+                            .size(LabelSize::Small)
+                            .color(Color::Error)
+                            .mb_2(),
+                    ),
+                )
+                .child(
+                    Label::new(description)
+                        .size(LabelSize::Small)
+                        .color(Color::Muted),
+                ),
+        )
+        .child(
+            h_flex()
+                .w_full()
+                .min_w_64()
+                .h_8()
+                .px_2()
+                .rounded_md()
+                .border_1()
+                .border_color(colors.border)
+                .bg(colors.editor_background)
+                .track_focus(&focus_handle)
+                .focus(|style| style.border_color(colors.border_focused))
+                .child(editor.clone()),
+        )
+        .into_any_element()
+}
+
+fn render_models_section(
+    form: &LlmProviderForm,
+    window: &mut Window,
+    cx: &mut Context,
+) -> impl IntoElement {
+    v_flex()
+        .mt_1()
+        .gap_2()
+        .child(
+            h_flex()
+                .justify_between()
+                .child(Label::new("Models"))
+                .child(
+                    Button::new("add-model", "Add Model")
+                        .start_icon(
+                            Icon::new(IconName::Plus)
+                                .size(IconSize::XSmall)
+                                .color(Color::Muted),
+                        )
+                        .label_size(LabelSize::Small)
+                        .on_click(cx.listener(|this, _, window, cx| {
+                            if let Some(form) = this.llm_provider_form.as_mut() {
+                                let index = form.models.len();
+                                form.models.push(ModelInput::new(index, window, cx));
+                            }
+                            cx.notify();
+                        })),
+                ),
+        )
+        .children(form.models.iter().enumerate().map(|(index, model)| {
+            render_model(form.kind, model, index, form.models.len(), window, cx)
+        }))
+}
+
+fn render_model(
+    kind: CompatibleProviderKind,
+    model: &ModelInput,
+    index: usize,
+    model_count: usize,
+    window: &mut Window,
+    cx: &mut Context,
+) -> AnyElement {
+    v_flex()
+        .p_2()
+        .gap_2()
+        .rounded_sm()
+        .border_1()
+        .border_dashed()
+        .border_color(cx.theme().colors().border.opacity(0.6))
+        .bg(cx.theme().colors().element_active.opacity(0.15))
+        .child(render_form_field(
+            "Model Name",
+            "The model's name in the provider's API.",
+            &model.name,
+            cx,
+        ))
+        .when(matches!(kind, CompatibleProviderKind::OpenAi), |this| {
+            this.child(render_form_field(
+                "Max Completion Tokens",
+                "Maximum completion tokens for OpenAI-compatible requests.",
+                &model.max_completion_tokens,
+                cx,
+            ))
+        })
+        .child(render_form_field(
+            "Max Output Tokens",
+            "The maximum number of tokens the model can output.",
+            &model.max_output_tokens,
+            cx,
+        ))
+        .child(render_form_field(
+            "Max Tokens",
+            "The model context window size.",
+            &model.max_tokens,
+            cx,
+        ))
+        .child(render_model_capabilities(kind, model, index, window, cx))
+        .when(model_count > 1, |this| {
+            this.child(
+                Button::new(("remove-model", index), "Remove Model")
+                    .start_icon(
+                        Icon::new(IconName::Trash)
+                            .size(IconSize::XSmall)
+                            .color(Color::Muted),
+                    )
+                    .label_size(LabelSize::Small)
+                    .style(ButtonStyle::Outlined)
+                    .full_width()
+                    .on_click(cx.listener(move |this, _, _window, cx| {
+                        if let Some(form) = this.llm_provider_form.as_mut()
+                            && index < form.models.len()
+                        {
+                            form.models.remove(index);
+                        }
+                        cx.notify();
+                    })),
+            )
+        })
+        .into_any_element()
+}
+
+fn render_model_capabilities(
+    kind: CompatibleProviderKind,
+    model: &ModelInput,
+    index: usize,
+    window: &mut Window,
+    cx: &mut Context,
+) -> impl IntoElement {
+    v_flex()
+        .gap_1()
+        .child(render_capability_checkbox(
+            "supports-tools",
+            index,
+            "Supports tools",
+            model.supports_tools,
+            |model, state| model.supports_tools = state,
+            cx,
+        ))
+        .child(render_capability_checkbox(
+            "supports-images",
+            index,
+            "Supports images",
+            model.supports_images,
+            |model, state| model.supports_images = state,
+            cx,
+        ))
+        .when(matches!(kind, CompatibleProviderKind::OpenAi), |this| {
+            this.child(render_capability_checkbox(
+                "supports-parallel-tool-calls",
+                index,
+                "Supports parallel_tool_calls",
+                model.supports_parallel_tool_calls,
+                |model, state| model.supports_parallel_tool_calls = state,
+                cx,
+            ))
+            .child(render_capability_checkbox(
+                "supports-prompt-cache-key",
+                index,
+                "Supports prompt_cache_key",
+                model.supports_prompt_cache_key,
+                |model, state| model.supports_prompt_cache_key = state,
+                cx,
+            ))
+            .child(render_capability_checkbox(
+                "supports-chat-completions",
+                index,
+                "Supports /chat/completions",
+                model.supports_chat_completions,
+                |model, state| model.supports_chat_completions = state,
+                cx,
+            ))
+            .when(model.supports_chat_completions.selected(), |this| {
+                this.child(render_capability_checkbox(
+                    "max-tokens-parameter",
+                    index,
+                    "Uses max_tokens for output limit",
+                    model.max_tokens_parameter,
+                    |model, state| model.max_tokens_parameter = state,
+                    cx,
+                ))
+            })
+            .child(render_capability_checkbox(
+                "supports-thinking",
+                index,
+                "Supports thinking",
+                model.supports_thinking,
+                |model, state| model.supports_thinking = state,
+                cx,
+            ))
+            .when(model.supports_thinking.selected(), |this| {
+                this.child(render_reasoning_effort_selector(
+                    model.reasoning_effort,
+                    index,
+                    window,
+                    cx,
+                ))
+                .when(model.supports_chat_completions.selected(), |this| {
+                    this.child(render_capability_checkbox(
+                        "interleaved-reasoning",
+                        index,
+                        "Preserves thinking in chat history",
+                        model.interleaved_reasoning,
+                        |model, state| model.interleaved_reasoning = state,
+                        cx,
+                    ))
+                })
+            })
+        })
+}
+
+fn render_capability_checkbox(
+    id: &'static str,
+    index: usize,
+    label: &'static str,
+    state: ToggleState,
+    update: fn(&mut ModelInput, ToggleState),
+    cx: &mut Context,
+) -> impl IntoElement {
+    Checkbox::new((id, index), state)
+        .label(label)
+        .on_click(cx.listener(move |this, checked, _window, cx| {
+            if let Some(form) = this.llm_provider_form.as_mut()
+                && let Some(model) = form.models.get_mut(index)
+            {
+                update(model, *checked);
+            }
+            cx.notify();
+        }))
+}
+
+fn render_reasoning_effort_selector(
+    selected: OpenAiReasoningEffort,
+    index: usize,
+    window: &mut Window,
+    cx: &mut Context,
+) -> impl IntoElement {
+    let settings_window = cx.weak_entity();
+    let menu = ContextMenu::build(window, cx, move |mut menu, _window, _cx| {
+        for effort in OpenAiReasoningEffort::OPENAI_COMPATIBLE_SELECTABLE {
+            let is_selected = effort == selected;
+            let settings_window = settings_window.clone();
+            menu.push_item(
+                ui::ContextMenuEntry::new(effort.label())
+                    .toggleable(IconPosition::End, is_selected)
+                    .handler(move |_window, cx| {
+                        settings_window
+                            .update(cx, |this, cx| {
+                                if let Some(form) = this.llm_provider_form.as_mut()
+                                    && let Some(model) = form.models.get_mut(index)
+                                {
+                                    model.reasoning_effort = effort;
+                                }
+                                cx.notify();
+                            })
+                            .ok();
+                    }),
+            );
+        }
+        menu
+    });
+
+    v_flex()
+        .gap_1()
+        .child(Label::new("Default reasoning effort").size(LabelSize::Small))
+        .child(
+            DropdownMenu::new(
+                ElementId::Name(format!("reasoning-effort-selector-{index}").into()),
+                selected.label(),
+                menu,
+            )
+            .style(DropdownStyle::Outlined)
+            .trigger_size(ButtonSize::Compact)
+            .full_width(true)
+            .aria_label("Default reasoning effort"),
+        )
+}
+
+fn render_form_error(error: SharedString) -> impl IntoElement {
+    h_flex()
+        .w_full()
+        .gap_2()
+        .child(
+            Icon::new(IconName::XCircle)
+                .size(IconSize::Small)
+                .color(Color::Error),
+        )
+        .child(Label::new(error).size(LabelSize::Small).color(Color::Error))
+}
+
+fn render_form_actions(cx: &mut Context) -> impl IntoElement {
+    h_flex()
+        .w_full()
+        .gap_1()
+        .justify_end()
+        .child(
+            Button::new("llm-provider-form-cancel", "Cancel").on_click(cx.listener(
+                |this, _, window, cx| {
+                    this.llm_provider_form = None;
+                    this.pop_sub_page(window, cx);
+                },
+            )),
+        )
+        .child(
+            Button::new("llm-provider-form-save", "Save Provider")
+                .style(ButtonStyle::Filled)
+                .on_click(cx.listener(|this, _, window, cx| {
+                    save_llm_provider_form(this, window, cx);
+                })),
+        )
+}
+
+struct LlmProviderFormValues {
+    kind: CompatibleProviderKind,
+    provider_name: String,
+    api_url: String,
+    api_key: String,
+    models: Vec,
+}
+
+struct ModelValues {
+    name: String,
+    max_completion_tokens: String,
+    max_output_tokens: String,
+    max_tokens: String,
+    reasoning_effort: OpenAiReasoningEffort,
+    supports_tools: bool,
+    supports_images: bool,
+    supports_parallel_tool_calls: bool,
+    supports_prompt_cache_key: bool,
+    supports_chat_completions: bool,
+    supports_thinking: bool,
+    interleaved_reasoning: bool,
+    max_tokens_parameter: bool,
+}
+
+enum ParsedModels {
+    OpenAi(Vec),
+    Anthropic(Vec),
+}
+
+fn save_llm_provider_form(
+    settings_window: &mut SettingsWindow,
+    window: &mut Window,
+    cx: &mut Context,
+) {
+    let values = {
+        let Some(form) = settings_window.llm_provider_form.as_ref() else {
+            return;
+        };
+        LlmProviderFormValues {
+            kind: form.kind,
+            provider_name: form.provider_name.read(cx).text(cx),
+            api_url: form.api_url.read(cx).text(cx),
+            api_key: form.api_key.read(cx).text(cx),
+            models: form
+                .models
+                .iter()
+                .map(|model| ModelValues {
+                    name: model.name.read(cx).text(cx),
+                    max_completion_tokens: model.max_completion_tokens.read(cx).text(cx),
+                    max_output_tokens: model.max_output_tokens.read(cx).text(cx),
+                    max_tokens: model.max_tokens.read(cx).text(cx),
+                    reasoning_effort: model.reasoning_effort,
+                    supports_tools: model.supports_tools.selected(),
+                    supports_images: model.supports_images.selected(),
+                    supports_parallel_tool_calls: model.supports_parallel_tool_calls.selected(),
+                    supports_prompt_cache_key: model.supports_prompt_cache_key.selected(),
+                    supports_chat_completions: model.supports_chat_completions.selected(),
+                    supports_thinking: model.supports_thinking.selected(),
+                    interleaved_reasoning: model.interleaved_reasoning.selected(),
+                    max_tokens_parameter: model.max_tokens_parameter.selected(),
+                })
+                .collect(),
+        }
+    };
+
+    let (provider_name, api_url, api_key, models) = match validate_llm_provider_form(&values, cx) {
+        Ok(value) => value,
+        Err(error) => {
+            if let Some(form) = settings_window.llm_provider_form.as_mut() {
+                form.error = Some(error);
+            }
+            cx.notify();
+            return;
+        }
+    };
+
+    let fs = ::global(cx);
+    cx.spawn_in(window, async move |this, cx| {
+        let result = async {
+            let provider_id = LanguageModelProviderId(provider_name.clone().into());
+            let settings_update = cx.update(|_window, cx| {
+                settings::update_settings_file_with_completion(fs, cx, move |settings, _cx| {
+                    let language_models = settings.language_models.get_or_insert_default();
+                    match models {
+                        ParsedModels::OpenAi(available_models) => {
+                            language_models
+                                .openai_compatible
+                                .get_or_insert_default()
+                                .insert(
+                                    Arc::from(provider_name.as_str()),
+                                    OpenAiCompatibleSettingsContent {
+                                        api_url: api_url.clone(),
+                                        available_models,
+                                        custom_headers: None,
+                                    },
+                                );
+                        }
+                        ParsedModels::Anthropic(available_models) => {
+                            language_models
+                                .anthropic_compatible
+                                .get_or_insert_default()
+                                .insert(
+                                    Arc::from(provider_name.as_str()),
+                                    AnthropicCompatibleSettingsContent {
+                                        api_url: api_url.clone(),
+                                        available_models,
+                                        custom_headers: None,
+                                    },
+                                );
+                        }
+                    }
+                })
+            })?;
+
+            settings_update
+                .await
+                .map_err(|_| anyhow::anyhow!("Settings update was canceled"))??;
+
+            let set_api_key = cx.update(|_window, cx| {
+                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))
+            })??;
+            set_api_key.await?;
+
+            cx.update(|window, cx| {
+                this.update(cx, |this, cx| {
+                    this.provider_configuration_views.remove(&provider_id);
+                    this.llm_provider_form = None;
+                    this.pop_sub_page(window, cx);
+                })
+            })??;
+
+            anyhow::Ok(())
+        }
+        .await;
+
+        if let Err(error) = result {
+            this.update(cx, |this, cx| {
+                if let Some(form) = this.llm_provider_form.as_mut() {
+                    form.error = Some(error.to_string().into());
+                }
+                cx.notify();
+            })?;
+        }
+
+        anyhow::Ok(())
+    })
+    .detach_and_log_err(cx);
+}
+
+fn validate_llm_provider_form(
+    values: &LlmProviderFormValues,
+    cx: &App,
+) -> Result<(String, String, String, ParsedModels), SharedString> {
+    let provider_name = values.provider_name.clone();
+    if provider_name.is_empty() {
+        return Err("Provider Name cannot be empty".into());
+    }
+
+    if LanguageModelRegistry::read_global(cx)
+        .providers()
+        .iter()
+        .any(|provider| {
+            provider.id().0.as_ref() == provider_name.as_str()
+                || provider.name().0.as_ref() == provider_name.as_str()
+        })
+    {
+        return Err("Provider Name is already taken by another provider".into());
+    }
+
+    let api_url = values.api_url.clone();
+    if api_url.is_empty() {
+        return Err("API URL cannot be empty".into());
+    }
+
+    let api_key = values.api_key.clone();
+    if api_key.is_empty() {
+        return Err("API Key cannot be empty".into());
+    }
+
+    let models = match values.kind {
+        CompatibleProviderKind::OpenAi => ParsedModels::OpenAi(
+            values
+                .models
+                .iter()
+                .map(parse_open_ai_model)
+                .collect::, _>>()?,
+        ),
+        CompatibleProviderKind::Anthropic => ParsedModels::Anthropic(
+            values
+                .models
+                .iter()
+                .map(parse_anthropic_model)
+                .collect::, _>>()?,
+        ),
+    };
+
+    let mut model_names = HashSet::new();
+    let model_names_are_unique = match &models {
+        ParsedModels::OpenAi(models) => models
+            .iter()
+            .all(|model| model_names.insert(model.name.clone())),
+        ParsedModels::Anthropic(models) => models
+            .iter()
+            .all(|model| model_names.insert(model.name.clone())),
+    };
+    if !model_names_are_unique {
+        return Err("Model Names must be unique".into());
+    }
+
+    Ok((provider_name, api_url, api_key, models))
+}
+
+fn parse_model_name(model: &ModelValues) -> Result {
+    if model.name.is_empty() {
+        return Err("Model Name cannot be empty".into());
+    }
+    Ok(model.name.clone())
+}
+
+fn parse_open_ai_model(
+    model: &ModelValues,
+) -> Result {
+    Ok(OpenAiCompatibleAvailableModel {
+        name: parse_model_name(model)?,
+        display_name: None,
+        max_completion_tokens: Some(parse_u64_field(
+            &model.max_completion_tokens,
+            "Max Completion Tokens",
+        )?),
+        max_output_tokens: Some(parse_u64_field(
+            &model.max_output_tokens,
+            "Max Output Tokens",
+        )?),
+        max_tokens: parse_u64_field(&model.max_tokens, "Max Tokens")?,
+        reasoning_effort: model.supports_thinking.then_some(model.reasoning_effort),
+        capabilities: OpenAiCompatibleModelCapabilities {
+            tools: model.supports_tools,
+            images: model.supports_images,
+            parallel_tool_calls: model.supports_parallel_tool_calls,
+            prompt_cache_key: model.supports_prompt_cache_key,
+            chat_completions: model.supports_chat_completions,
+            interleaved_reasoning: model.supports_thinking
+                && model.supports_chat_completions
+                && model.interleaved_reasoning,
+            max_tokens_parameter: model.supports_chat_completions && model.max_tokens_parameter,
+        },
+    })
+}
+
+fn parse_anthropic_model(
+    model: &ModelValues,
+) -> Result {
+    Ok(AnthropicCompatibleAvailableModel {
+        name: parse_model_name(model)?,
+        display_name: None,
+        max_tokens: parse_u64_field(&model.max_tokens, "Max Tokens")?,
+        tool_override: None,
+        max_output_tokens: Some(parse_u64_field(
+            &model.max_output_tokens,
+            "Max Output Tokens",
+        )?),
+        default_temperature: None,
+        extra_beta_headers: Vec::new(),
+        mode: None,
+        capabilities: AnthropicCompatibleModelCapabilities {
+            tools: model.supports_tools,
+            images: model.supports_images,
+            prompt_caching: false,
+        },
+    })
+}
+
+fn parse_u64_field(value: &str, name: &str) -> Result {
+    value
+        .parse::()
+        .map_err(|_| format!("{name} must be a number").into())
+}
diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs
index b371fb68ccbe30..34a2ac271d494e 100644
--- a/crates/settings_ui/src/settings_ui.rs
+++ b/crates/settings_ui/src/settings_ui.rs
@@ -60,7 +60,7 @@ use crate::components::{
     text_field_a11y_state, theme_picker,
 };
 use crate::pages::{
-    CustomAgentForm, McpServerForm, render_input_audio_device_dropdown,
+    CustomAgentForm, LlmProviderForm, McpServerForm, render_input_audio_device_dropdown,
     render_output_audio_device_dropdown,
 };
 
@@ -957,6 +957,12 @@ pub struct SettingsWindow {
     /// Directory path of the skill whose share link was most recently copied,
     /// used to show a transient "copied" checkmark on its share button.
     pub(crate) last_copied_skill_directory_path: Option,
+    /// State for the active "add OpenAI/Anthropic-compatible provider" form sub-page, if open.
+    pub(crate) llm_provider_form: Option,
+    /// Stable focus handle for the LLM "Add Provider" button, so it can show a
+    /// focus ring when the page auto-focuses it on open (which happens via mouse,
+    /// where `focus_visible` styling would otherwise be suppressed).
+    pub(crate) llm_provider_add_focus_handle: FocusHandle,
     /// State for the active "add/edit custom MCP server" form sub-page, if open.
     pub(crate) mcp_server_form: Option,
     /// Stable focus handle for the MCP "Add Server" button, so it can show a
@@ -1983,6 +1989,8 @@ impl SettingsWindow {
             provider_configuration_views: HashMap::default(),
             configuring_provider: None,
             last_copied_skill_directory_path: None,
+            llm_provider_form: None,
+            llm_provider_add_focus_handle: cx.focus_handle(),
             mcp_server_form: None,
             mcp_add_server_focus_handle: cx.focus_handle(),
             custom_agent_form: None,
@@ -3707,6 +3715,8 @@ impl SettingsWindow {
         if let Some(current_sub_page) = self.sub_page_stack.last() {
             let is_skills_page =
                 current_sub_page.link.json_path == Some(AGENT_SKILLS_SETTINGS_PATH);
+            let is_llm_providers_page = current_sub_page.link.json_path == Some("llm_providers")
+                && current_sub_page.link.title.as_ref() == "LLM Providers";
             let is_external_agents_page = current_sub_page.link.json_path == Some("agent_servers");
             let is_mcp_servers_page = current_sub_page.link.json_path == Some("context_servers");
 
@@ -3747,6 +3757,9 @@ impl SettingsWindow {
                                     })),
                             )
                         })
+                        .when(is_llm_providers_page, |this| {
+                            this.child(pages::render_add_llm_provider_popover(self, window, cx))
+                        })
                         .when(is_skills_page, |this| {
                             this.child(
                                 Button::new("open-skill-creator", "Create Skill")
@@ -4027,7 +4040,11 @@ impl SettingsWindow {
     /// This function will create a new settings file if one doesn't exist
     /// if the current file is a project settings with a valid worktree id
     /// We do this because the settings ui allows initializing project settings
-    fn open_current_settings_file(&mut self, window: &mut Window, cx: &mut Context) {
+    pub(crate) fn open_current_settings_file(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context,
+    ) {
         match &self.current_file {
             SettingsUiFile::User => {
                 let Some(original_window) = self.original_window else {
@@ -5267,6 +5284,8 @@ pub mod test {
                 provider_configuration_views: HashMap::default(),
                 configuring_provider: None,
                 last_copied_skill_directory_path: None,
+                llm_provider_form: None,
+                llm_provider_add_focus_handle: cx.focus_handle(),
                 mcp_server_form: None,
                 mcp_add_server_focus_handle: cx.focus_handle(),
                 custom_agent_form: None,
@@ -5404,6 +5423,8 @@ pub mod test {
             provider_configuration_views: HashMap::default(),
             configuring_provider: None,
             last_copied_skill_directory_path: None,
+            llm_provider_form: None,
+            llm_provider_add_focus_handle: cx.focus_handle(),
             mcp_server_form: None,
             mcp_add_server_focus_handle: cx.focus_handle(),
             custom_agent_form: None,

From c35650a88476587c9272eede79a2960d143b5a59 Mon Sep 17 00:00:00 2001
From: Albert Bogusz 
Date: Wed, 1 Jul 2026 15:51:10 +0100
Subject: [PATCH 015/422] Support checking out remote HEAD refs (#57648)

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments N/A
- [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

Remote `HEAD` refs such as `origin/HEAD` and `upstream/HEAD` are
symbolic refs that point to a remote's default branch. These refs can
appear in the branch picker, but selecting them shows this error prompt:

![Error
Popup](https://github.com/user-attachments/assets/f30f9c9a-8f4e-43d9-8830-282eb17a3572)

This happens because the local branch name is directly derived from the
selected ref, resulting in an attempted checkout of a local branch named
`HEAD`.

This change follows up on feedback from #57588, which just filtered
remote `HEAD` refs out of the branch picker to prevent the error prompt.
As mentioned in [this
comment](https://github.com/zed-industries/zed/pull/57588#issuecomment-4533477336),
I agree that the preferred behaviour should be to instead allow checking
them out - useful for repos with multiple remotes such as an upstream.

For example, if `refs/remotes/origin/HEAD` points to
`refs/remotes/origin/main`, selecting `origin/HEAD` now behaves like
selecting `origin/main`, switching to the corresponding local `main`
branch instead of trying to create or check out a branch named `HEAD`.
This does not fetch or pull from the remote; it uses the locally known
remote refs.

Release Notes:

- Fixed selecting remote HEAD refs from the branch picker

---------

Co-authored-by: Smit Barmase 
---
 crates/git/src/repository.rs | 207 ++++++++++++++++++++++++++++-------
 1 file changed, 165 insertions(+), 42 deletions(-)

diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs
index 93338de75ccf4a..f09c13f54ab26c 100644
--- a/crates/git/src/repository.rs
+++ b/crates/git/src/repository.rs
@@ -2131,6 +2131,13 @@ impl GitRepository for RealGitRepository {
                     .await
                     .is_ok()
                 {
+                    let name = match git_binary.run(&["symbolic-ref", &remote_ref]).await {
+                        Ok(resolved) => resolved
+                            .strip_prefix("refs/remotes/")
+                            .map(str::to_owned)
+                            .unwrap_or(name),
+                        Err(_) => name,
+                    };
                     let (_, branch_name) =
                         name.split_once('/').context("Unexpected branch format")?;
                     let local_branch_ref = format!("refs/heads/{branch_name}");
@@ -3934,6 +3941,52 @@ mod tests {
         git_command(path, ["init", "-b", "main"]);
     }
 
+    fn clone_remote_repository_with_main_and_feature(temp_dir: &Path) -> (PathBuf, PathBuf) {
+        let remote_directory = temp_dir.join("remote.git");
+        let seed_directory = temp_dir.join("seed");
+        let clone_directory = temp_dir.join("clone");
+
+        git_command(
+            temp_dir,
+            [
+                OsString::from("init"),
+                OsString::from("--bare"),
+                OsString::from("-b"),
+                OsString::from("main"),
+                remote_directory.as_os_str().into(),
+            ],
+        );
+        git_init_repo(&seed_directory);
+        fs::write(seed_directory.join("file.txt"), "main").unwrap();
+        git_command(&seed_directory, ["add", "file.txt"]);
+        git_command(&seed_directory, ["commit", "-m", "initial"]);
+        git_command(&seed_directory, ["switch", "-c", "feature"]);
+        fs::write(seed_directory.join("feature.txt"), "feature").unwrap();
+        git_command(&seed_directory, ["add", "feature.txt"]);
+        git_command(&seed_directory, ["commit", "-m", "feature"]);
+        git_command(
+            &seed_directory,
+            [
+                OsString::from("remote"),
+                OsString::from("add"),
+                OsString::from("origin"),
+                remote_directory.as_os_str().into(),
+            ],
+        );
+        git_command(&seed_directory, ["push", "-u", "origin", "main"]);
+        git_command(&seed_directory, ["push", "-u", "origin", "feature"]);
+        git_command(
+            temp_dir,
+            [
+                OsString::from("clone"),
+                remote_directory.as_os_str().into(),
+                clone_directory.as_os_str().into(),
+            ],
+        );
+
+        (remote_directory, clone_directory)
+    }
+
     fn test_commit_envs() -> HashMap {
         let mut env = checkpoint_author_envs();
         env.insert("GIT_ASKPASS".to_string(), "false".to_string());
@@ -4078,50 +4131,11 @@ mod tests {
         cx.executor().allow_parking();
 
         let temp_dir = tempfile::tempdir().unwrap();
-        let remote_dir = temp_dir.path().join("remote.git");
-        let seed_dir = temp_dir.path().join("seed");
-        let clone_dir = temp_dir.path().join("clone");
-
-        git_command(
-            temp_dir.path(),
-            [
-                OsString::from("init"),
-                OsString::from("--bare"),
-                OsString::from("-b"),
-                OsString::from("main"),
-                remote_dir.as_os_str().into(),
-            ],
-        );
-        git_init_repo(&seed_dir);
-        fs::write(seed_dir.join("file.txt"), "main").unwrap();
-        git_command(&seed_dir, ["add", "file.txt"]);
-        git_command(&seed_dir, ["commit", "-m", "initial"]);
-        git_command(&seed_dir, ["switch", "-c", "feature"]);
-        fs::write(seed_dir.join("feature.txt"), "feature").unwrap();
-        git_command(&seed_dir, ["add", "feature.txt"]);
-        git_command(&seed_dir, ["commit", "-m", "feature"]);
-        git_command(
-            &seed_dir,
-            [
-                OsString::from("remote"),
-                OsString::from("add"),
-                OsString::from("origin"),
-                remote_dir.as_os_str().into(),
-            ],
-        );
-        git_command(&seed_dir, ["push", "-u", "origin", "main"]);
-        git_command(&seed_dir, ["push", "-u", "origin", "feature"]);
-        git_command(
-            temp_dir.path(),
-            [
-                OsString::from("clone"),
-                remote_dir.as_os_str().into(),
-                clone_dir.as_os_str().into(),
-            ],
-        );
+        let (_remote_directory, clone_directory) =
+            clone_remote_repository_with_main_and_feature(temp_dir.path());
 
         let repository = RealGitRepository::new(
-            &clone_dir.join(".git"),
+            &clone_directory.join(".git"),
             None,
             Some("git".into()),
             cx.executor(),
@@ -4184,6 +4198,115 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_change_branch_resolves_remote_head_to_tracking_branch(cx: &mut TestAppContext) {
+        disable_git_global_config();
+        cx.executor().allow_parking();
+
+        let temp_dir = tempfile::tempdir().unwrap();
+        let (_remote_directory, clone_directory) =
+            clone_remote_repository_with_main_and_feature(temp_dir.path());
+
+        let repository = RealGitRepository::new(
+            &clone_directory.join(".git"),
+            None,
+            Some("git".into()),
+            cx.executor(),
+        )
+        .unwrap();
+        let git = repository.git_binary_in_worktree().unwrap();
+        git.run(&[
+            "symbolic-ref",
+            "refs/remotes/origin/HEAD",
+            "refs/remotes/origin/feature",
+        ])
+        .await
+        .unwrap();
+        assert_eq!(
+            git.run(&["symbolic-ref", "refs/remotes/origin/HEAD"])
+                .await
+                .unwrap(),
+            "refs/remotes/origin/feature"
+        );
+        assert!(
+            git.run(&["show-ref", "--verify", "--quiet", "refs/heads/feature"])
+                .await
+                .is_err()
+        );
+
+        repository
+            .change_branch("origin/HEAD".to_string())
+            .await
+            .unwrap();
+
+        let git = repository.git_binary_in_worktree().unwrap();
+        assert_eq!(
+            git.run(&["branch", "--show-current"]).await.unwrap(),
+            "feature"
+        );
+        assert_eq!(
+            git.run(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}",])
+                .await
+                .unwrap(),
+            "origin/feature"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_change_branch_resolves_non_origin_remote_head(cx: &mut TestAppContext) {
+        disable_git_global_config();
+        cx.executor().allow_parking();
+
+        let temp_dir = tempfile::tempdir().unwrap();
+        let (remote_directory, clone_directory) =
+            clone_remote_repository_with_main_and_feature(temp_dir.path());
+
+        git_command(
+            &clone_directory,
+            [
+                OsString::from("remote"),
+                OsString::from("add"),
+                OsString::from("upstream"),
+                remote_directory.as_os_str().into(),
+            ],
+        );
+        git_command(&clone_directory, ["fetch", "upstream"]);
+
+        let repository = RealGitRepository::new(
+            &clone_directory.join(".git"),
+            None,
+            Some("git".into()),
+            cx.executor(),
+        )
+        .unwrap();
+        let git = repository.git_binary_in_worktree().unwrap();
+        git.run(&[
+            "symbolic-ref",
+            "refs/remotes/upstream/HEAD",
+            "refs/remotes/upstream/main",
+        ])
+        .await
+        .unwrap();
+        git.run(&["checkout", "-b", "scratch"]).await.unwrap();
+
+        repository
+            .change_branch("upstream/HEAD".to_string())
+            .await
+            .unwrap();
+
+        let git = repository.git_binary_in_worktree().unwrap();
+        assert_eq!(
+            git.run(&["branch", "--show-current"]).await.unwrap(),
+            "main"
+        );
+        assert_eq!(
+            git.run(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}",])
+                .await
+                .unwrap(),
+            "upstream/main"
+        );
+    }
+
     #[gpui::test]
     fn test_real_git_repository_new_rejects_malformed_git_file(cx: &mut TestAppContext) {
         disable_git_global_config();

From 3648fe6f19644fa4fcbd4f1db3e5888efb8269c1 Mon Sep 17 00:00:00 2001
From: Cameron Mcloughlin 
Date: Wed, 1 Jul 2026 15:55:30 +0100
Subject: [PATCH 016/422] agent: Sandboxing polish (#60173)

Removes git sandbox feature

The reason is essentially:
- write access to a `.git` dir can be trivially escalated to unsandboxed
access
- therefore, it is misleading to offer git access separate from
unsandboxed access
- instead, we encourage the model to use `--no-optional-locks` to avoid
needing write access to `git status`, etc.


Adds sandboxing to fetch tool



---

Release Notes:

- N/A or Added/Fixed/Improved ...

---------

Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
---
 crates/acp_thread/src/acp_thread.rs           |    4 +-
 crates/acp_thread/src/terminal.rs             |   37 +-
 crates/agent/src/db.rs                        |    5 +-
 crates/agent/src/sandboxing.rs                |  151 +-
 crates/agent/src/templates.rs                 |   40 +-
 crates/agent/src/templates/system_prompt.hbs  |   19 +-
 crates/agent/src/tests/mod.rs                 |  182 ++-
 crates/agent/src/thread.rs                    |  101 +-
 crates/agent/src/tools.rs                     |    2 +-
 crates/agent/src/tools/fetch_tool.rs          |   74 +-
 crates/agent/src/tools/terminal_tool.rs       |  106 +-
 .../tools/terminal_tool/sandbox_git_paths.rs  | 1416 -----------------
 crates/agent_settings/src/agent_settings.rs   |    5 -
 .../src/conversation_view/thread_view.rs      |   99 +-
 .../agent_ui/src/ui/sandbox_status_tooltip.rs |   19 +-
 crates/sandbox/README.md                      |   47 +-
 crates/sandbox/src/bwrap_test_helper.rs       |   66 +-
 crates/sandbox/src/linux_bubblewrap.rs        |   41 +-
 crates/sandbox/src/macos_seatbelt.rs          |   39 +-
 crates/sandbox/src/sandbox.rs                 |  440 +++--
 crates/sandbox/src/windows_wsl.rs             |  136 +-
 crates/sandbox/src/wsl_sandbox_test_helper.rs |   10 +-
 crates/settings_content/src/agent.rs          |   13 -
 .../settings_ui/src/pages/sandbox_settings.rs |   33 +-
 docs/.doc-examples/reference.md               |    2 +
 docs/src/ai/sandboxing.md                     |   49 +-
 docs/src/ai/tools.md                          |    2 +
 nix/tests/sandboxing/default.nix              |  130 +-
 28 files changed, 943 insertions(+), 2325 deletions(-)
 delete mode 100644 crates/agent/src/tools/terminal_tool/sandbox_git_paths.rs

diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs
index 87c9dd1c42c5de..bf5b97607bbc3c 100644
--- a/crates/acp_thread/src/acp_thread.rs
+++ b/crates/acp_thread/src/acp_thread.rs
@@ -176,9 +176,7 @@ pub struct SandboxAuthorizationDetails {
     /// builds still render the network request.
     #[serde(default, alias = "network")]
     pub network_all_hosts: bool,
-    /// Whether the command requested access to protected `.git` directories.
-    #[serde(default)]
-    pub allow_git_access: bool,
+
     #[serde(default)]
     pub allow_fs_write_all: bool,
     #[serde(default)]
diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs
index 7526ad4a1b3cfe..3bc33e2a957edc 100644
--- a/crates/acp_thread/src/terminal.rs
+++ b/crates/acp_thread/src/terminal.rs
@@ -46,14 +46,11 @@ pub struct SandboxWrap {
     pub extra_write_paths: Vec,
     /// Outbound network access explicitly approved for this command.
     pub network: SandboxNetworkAccess,
-    /// The project's `.git` directories (worktree `.git`, linked-worktree common
-    /// dirs, discovered repos). Protected by default; made writable when
-    /// `allow_git_access` is set. Computed by the agent because locating them
-    /// needs Git knowledge the sandbox layer can't derive itself.
-    pub git_dirs: Vec,
-    /// Whether the user approved access to the protected `.git` directories.
-    pub allow_git_access: bool,
-    /// Allow unrestricted filesystem writes (ignores all writable paths).
+    /// Additional paths that should remain readable but not writable, even when
+    /// they fall under writable paths.
+    pub protected_paths: Vec,
+    /// Allow unrestricted filesystem writes except for protected paths (ignores
+    /// ordinary writable paths).
     pub allow_fs_write: bool,
     /// Whether the project (and therefore this terminal) is local. The
     /// enforcing proxy binds a loopback port on this host, so it can only
@@ -155,8 +152,13 @@ impl SandboxWrap {
     /// path) rather than passing a re-resolvable path. A location that can't be
     /// captured (e.g. it doesn't exist) is dropped from the grant — fail-closed.
     fn to_policy(&self) -> sandbox::SandboxPolicy {
+        let protected_paths = self
+            .protected_paths
+            .iter()
+            .filter_map(|path| sandbox::HostFilesystemLocation::new(path).ok())
+            .collect();
         let fs = if self.allow_fs_write {
-            sandbox::SandboxFsPolicy::Unrestricted
+            sandbox::SandboxFsPolicy::Unrestricted { protected_paths }
         } else {
             let writable_paths = self
                 .writable_paths
@@ -169,7 +171,10 @@ impl SandboxWrap {
                     sandbox::HostFilesystemLocation::new(path).ok()
                 })
                 .collect();
-            sandbox::SandboxFsPolicy::Restricted { writable_paths }
+            sandbox::SandboxFsPolicy::Restricted {
+                writable_paths,
+                protected_paths,
+            }
         };
         let network = match &self.network {
             SandboxNetworkAccess::None => sandbox::SandboxNetPolicy::Blocked,
@@ -182,17 +187,7 @@ impl SandboxWrap {
                     .collect(),
             },
         };
-        let git_dirs = self
-            .git_dirs
-            .iter()
-            .filter_map(|path| sandbox::HostFilesystemLocation::new(path).ok())
-            .collect();
-        let git = if self.allow_git_access {
-            sandbox::GitSandboxPolicy::Allowed { git_dirs }
-        } else {
-            sandbox::GitSandboxPolicy::Denied { git_dirs }
-        };
-        sandbox::SandboxPolicy { fs, network, git }
+        sandbox::SandboxPolicy { fs, network }
     }
 }
 
diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs
index 9d0541475bba68..7d69e03c325a93 100644
--- a/crates/agent/src/db.rs
+++ b/crates/agent/src/db.rs
@@ -107,9 +107,7 @@ pub struct DbSandboxGrants {
     /// granted.
     #[serde(default)]
     pub allow_fs_write_all: bool,
-    /// Whether access to protected Git directories (`.git`) was granted.
-    #[serde(default)]
-    pub allow_git_access: bool,
+
     /// Whether the model-requested fully-unsandboxed escape was granted.
     #[serde(default)]
     pub unsandboxed: bool,
@@ -950,7 +948,6 @@ mod tests {
             write_paths: vec![PathBuf::from("/tmp/build")],
             network_hosts: vec!["github.com".to_string(), "*.npmjs.org".to_string()],
             network_any_host: false,
-            allow_git_access: true,
             allow_fs_write_all: false,
             unsandboxed: true,
             sandbox_fallback: true,
diff --git a/crates/agent/src/sandboxing.rs b/crates/agent/src/sandboxing.rs
index e8597a9412dd00..e8d6589601eef4 100644
--- a/crates/agent/src/sandboxing.rs
+++ b/crates/agent/src/sandboxing.rs
@@ -30,9 +30,7 @@ use feature_flags::{FeatureFlagAppExt as _, SandboxingFeatureFlag};
 use gpui::App;
 use http_proxy::HostPattern;
 use project::Project;
-use sandbox::{
-    GitSandboxPolicy, HostFilesystemLocation, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy,
-};
+use sandbox::{HostFilesystemLocation, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy};
 use settings::Settings;
 use std::path::PathBuf;
 
@@ -48,14 +46,10 @@ pub fn sandbox_worktree_writable_paths(project: &Project, cx: &App) -> Vec Vec {
     let mut git_dirs = Vec::new();
 
@@ -86,9 +80,9 @@ pub fn sandbox_git_dirs(project: &Project, cx: &App) -> Vec {
 /// UI renders and enforcement builds from. "No sandbox" is its own variant
 /// rather than a maximally-permissive [`SandboxPolicy`] so that a wide-open but
 /// real sandbox (e.g. `allow_fs_write_all` + `allow_all_hosts`) stays
-/// distinguishable from running with no sandbox at all — the two grant the same
-/// filesystem/network reach but only the latter means the command runs with
-/// ambient permissions.
+/// distinguishable from running with no sandbox at all. The sandbox still
+/// enforces invariants such as read-only Git metadata, while unsandboxed commands
+/// run with ambient permissions.
 #[derive(Clone, Debug, Eq, PartialEq)]
 pub enum ThreadSandbox {
     /// No OS sandbox is applied; commands run with ambient permissions.
@@ -120,27 +114,20 @@ impl ThreadSandbox {
         matches!(self, ThreadSandbox::Unsandboxed)
     }
 
-    /// Attach the project's Git policy to a sandboxed layer. The settings/grants
-    /// don't know the project's `.git` locations, so the caller computes them
-    /// (via [`sandbox_git_dirs`]) and passes whether this layer grants Git
-    /// access. A no-op for the `Unsandboxed` variant.
-    pub fn with_git(self, allowed: bool, git_dirs: Vec) -> ThreadSandbox {
+    /// Attach the project's protected paths to a sandboxed layer. The settings
+    /// and grants don't know the project's `.git` locations, so the caller
+    /// computes them via [`sandbox_git_dirs`]. A no-op for `Unsandboxed`.
+    pub fn with_protected_paths(self, protected_paths: Vec) -> ThreadSandbox {
         match self {
             ThreadSandbox::Unsandboxed => ThreadSandbox::Unsandboxed,
             ThreadSandbox::Sandboxed(policy) => {
-                // Capture each `.git` location (pinning its inode / canonical
-                // path). A location that can't be captured (e.g. doesn't exist)
-                // is dropped — fail-closed.
-                let git_dirs = git_dirs
+                // Capture each protected location (pinning its inode / canonical
+                // path). A location that can't be captured is dropped — fail-closed.
+                let protected_paths = protected_paths
                     .into_iter()
                     .filter_map(|path| HostFilesystemLocation::new(path).ok())
                     .collect();
-                let git = if allowed {
-                    GitSandboxPolicy::Allowed { git_dirs }
-                } else {
-                    GitSandboxPolicy::Denied { git_dirs }
-                };
-                ThreadSandbox::Sandboxed(policy.with_git(git))
+                ThreadSandbox::Sandboxed(policy.with_protected_paths(protected_paths))
             }
         }
     }
@@ -164,7 +151,9 @@ pub fn settings_thread_sandbox(persistent: &SandboxPermissions) -> ThreadSandbox
 /// from [`ThreadSandboxGrants::to_policy`].
 pub fn settings_sandbox_policy(persistent: &SandboxPermissions) -> SandboxPolicy {
     let fs = if persistent.allow_fs_write_all {
-        SandboxFsPolicy::Unrestricted
+        SandboxFsPolicy::Unrestricted {
+            protected_paths: Vec::new(),
+        }
     } else {
         SandboxFsPolicy::Restricted {
             writable_paths: persistent
@@ -172,6 +161,7 @@ pub fn settings_sandbox_policy(persistent: &SandboxPermissions) -> SandboxPolicy
                 .iter()
                 .filter_map(|path| HostFilesystemLocation::new(path).ok())
                 .collect(),
+            protected_paths: Vec::new(),
         }
     };
     let network = if persistent.allow_all_hosts {
@@ -183,13 +173,7 @@ pub fn settings_sandbox_policy(persistent: &SandboxPermissions) -> SandboxPolicy
             allowed_domains: persistent.network_hosts.clone(),
         }
     };
-    // The persistent settings don't know the project's `.git` locations; the UI
-    // layer attaches the real Git policy via `SandboxPolicy::with_git`.
-    SandboxPolicy {
-        fs,
-        network,
-        git: GitSandboxPolicy::default(),
-    }
+    SandboxPolicy { fs, network }
 }
 
 /// Whether agent-run terminal commands should be wrapped in an OS-level
@@ -275,8 +259,7 @@ impl NetworkRequest {
 pub(crate) struct SandboxRequest {
     /// Outbound network access requested for this command.
     pub network: NetworkRequest,
-    /// Allow access to protected Git metadata paths.
-    pub allow_git_access: bool,
+
     /// Allow unrestricted filesystem writes (the broad escape hatch).
     pub allow_fs_write_all: bool,
     /// Run the command fully outside the sandbox.
@@ -291,7 +274,6 @@ impl SandboxRequest {
     /// scope, and therefore needs user approval.
     pub fn needs_escalation(&self) -> bool {
         self.network.is_requested()
-            || self.allow_git_access
             || self.allow_fs_write_all
             || self.unsandboxed
             || !self.write_paths.is_empty()
@@ -312,7 +294,6 @@ pub(crate) struct ThreadSandboxGrants {
     /// Host patterns granted network access for the thread. Each covers its
     /// whole subdomain space; redundant entries are pruned on insert.
     network_hosts: Vec,
-    allow_git_access: bool,
     allow_fs_write_all: bool,
     unsandboxed: bool,
     /// Whether the user approved running commands *without* a sandbox for the
@@ -353,14 +334,13 @@ impl ThreadSandboxGrants {
         if !self.network_covered(&request.network, persistent) {
             return false;
         }
-        if request.allow_git_access && !(self.allow_git_access || persistent.allow_git_access) {
-            return false;
-        }
+
         if request.allow_fs_write_all && !(self.allow_fs_write_all || persistent.allow_fs_write_all)
         {
             return false;
         }
-        // A full-access write grant covers any concrete write request.
+        // A full-access write grant covers any concrete write request at the
+        // authorization layer; protected paths are enforced by the sandbox.
         if self.allow_fs_write_all || persistent.allow_fs_write_all {
             return true;
         }
@@ -412,12 +392,6 @@ impl ThreadSandboxGrants {
         self.unsandboxed
     }
 
-    /// Whether the user approved access to protected Git directories for the
-    /// rest of the thread.
-    pub fn git_access_granted(&self) -> bool {
-        self.allow_git_access
-    }
-
     /// Record that the user approved running commands unsandboxed for the rest
     /// of the thread when the sandbox can't be created. Only the Bubblewrap
     /// sandboxes (Linux directly, Windows via WSL) can fail to create a
@@ -447,7 +421,9 @@ impl ThreadSandboxGrants {
     /// from [`settings_sandbox_policy`].
     pub fn to_policy(&self) -> SandboxPolicy {
         let fs = if self.allow_fs_write_all {
-            SandboxFsPolicy::Unrestricted
+            SandboxFsPolicy::Unrestricted {
+                protected_paths: Vec::new(),
+            }
         } else {
             SandboxFsPolicy::Restricted {
                 writable_paths: self
@@ -455,6 +431,7 @@ impl ThreadSandboxGrants {
                     .iter()
                     .filter_map(|path| HostFilesystemLocation::new(path).ok())
                     .collect(),
+                protected_paths: Vec::new(),
             }
         };
         let network = if self.network_any_host {
@@ -470,13 +447,7 @@ impl ThreadSandboxGrants {
                     .collect(),
             }
         };
-        // Grants don't carry the project's `.git` locations; the UI layer
-        // attaches the real Git policy via `SandboxPolicy::with_git`.
-        SandboxPolicy {
-            fs,
-            network,
-            git: GitSandboxPolicy::default(),
-        }
+        SandboxPolicy { fs, network }
     }
 
     /// Serialize these grants for persistence in the thread's database row.
@@ -491,7 +462,6 @@ impl ThreadSandboxGrants {
                 .map(|host| host.to_string())
                 .collect(),
             network_any_host: self.network_any_host,
-            allow_git_access: self.allow_git_access,
             allow_fs_write_all: self.allow_fs_write_all,
             unsandboxed: self.unsandboxed,
             sandbox_fallback: self.sandbox_fallback,
@@ -514,7 +484,6 @@ impl ThreadSandboxGrants {
         Self {
             network_any_host: db.network_any_host,
             network_hosts,
-            allow_git_access: db.allow_git_access,
             allow_fs_write_all: db.allow_fs_write_all,
             unsandboxed: db.unsandboxed,
             sandbox_fallback: db.sandbox_fallback,
@@ -534,7 +503,6 @@ impl ThreadSandboxGrants {
                 }
             }
         }
-        self.allow_git_access |= request.allow_git_access;
         self.allow_fs_write_all |= request.allow_fs_write_all;
         self.unsandboxed |= request.unsandboxed;
         for path in &request.write_paths {
@@ -586,9 +554,6 @@ impl ThreadSandboxGrants {
         }
         SandboxRequest {
             network,
-            allow_git_access: persistent.allow_git_access
-                || self.allow_git_access
-                || request.allow_git_access,
             allow_fs_write_all: persistent.allow_fs_write_all
                 || self.allow_fs_write_all
                 || request.allow_fs_write_all,
@@ -643,7 +608,6 @@ mod tests {
     fn request(network: NetworkRequest, all: bool, paths: &[&str]) -> SandboxRequest {
         SandboxRequest {
             network,
-            allow_git_access: false,
             allow_fs_write_all: all,
             unsandboxed: false,
             write_paths: paths.iter().map(PathBuf::from).collect(),
@@ -653,7 +617,6 @@ mod tests {
     fn unsandboxed_request() -> SandboxRequest {
         SandboxRequest {
             network: NetworkRequest::None,
-            allow_git_access: false,
             allow_fs_write_all: false,
             unsandboxed: true,
             write_paths: Vec::new(),
@@ -675,6 +638,7 @@ mod tests {
                     .iter()
                     .map(|p| HostFilesystemLocation::new(p).expect("capture temp dir"))
                     .collect(),
+                protected_paths: Vec::new(),
             },
             network: if hosts.is_empty() {
                 SandboxNetPolicy::Blocked
@@ -683,7 +647,6 @@ mod tests {
                     allowed_domains: hosts.iter().map(|h| h.to_string()).collect(),
                 }
             },
-            git: GitSandboxPolicy::default(),
         };
 
         // Unsandboxed on either side wins — the agent runs with ambient access.
@@ -783,7 +746,8 @@ mod tests {
             SandboxFsPolicy::Restricted {
                 writable_paths: vec![
                     HostFilesystemLocation::new(build_dir.path()).expect("capture temp dir")
-                ]
+                ],
+                protected_paths: Vec::new(),
             }
         );
         assert_eq!(
@@ -798,7 +762,8 @@ mod tests {
         assert_eq!(
             empty.fs,
             SandboxFsPolicy::Restricted {
-                writable_paths: Vec::new()
+                writable_paths: Vec::new(),
+                protected_paths: Vec::new(),
             }
         );
         assert_eq!(empty.network, SandboxNetPolicy::Blocked);
@@ -807,7 +772,12 @@ mod tests {
         let mut broad = ThreadSandboxGrants::default();
         broad.record(&request(NetworkRequest::AnyHost, true, &[]));
         let policy = broad.to_policy();
-        assert_eq!(policy.fs, SandboxFsPolicy::Unrestricted);
+        assert_eq!(
+            policy.fs,
+            SandboxFsPolicy::Unrestricted {
+                protected_paths: Vec::new(),
+            }
+        );
         assert_eq!(policy.network, SandboxNetPolicy::Unrestricted);
     }
 
@@ -828,7 +798,8 @@ mod tests {
             SandboxFsPolicy::Restricted {
                 writable_paths: vec![
                     HostFilesystemLocation::new(log_dir.path()).expect("capture temp dir")
-                ]
+                ],
+                protected_paths: Vec::new(),
             }
         );
         assert_eq!(
@@ -844,7 +815,12 @@ mod tests {
             ..Default::default()
         };
         let policy = settings_sandbox_policy(&unrestricted);
-        assert_eq!(policy.fs, SandboxFsPolicy::Unrestricted);
+        assert_eq!(
+            policy.fs,
+            SandboxFsPolicy::Unrestricted {
+                protected_paths: Vec::new(),
+            }
+        );
         assert_eq!(policy.network, SandboxNetPolicy::Unrestricted);
     }
 
@@ -1030,33 +1006,6 @@ mod tests {
         assert!(!covers(&grants, &request(NetworkRequest::None, true, &[])));
     }
 
-    #[test]
-    fn git_access_grant_tracked_independently() {
-        let mut git_request = request(NetworkRequest::None, false, &[]);
-        git_request.allow_git_access = true;
-
-        let mut grants = ThreadSandboxGrants::default();
-        assert!(!covers(&grants, &git_request));
-
-        grants.record(&git_request);
-        assert!(covers(&grants, &git_request));
-        assert!(!covers(
-            &grants,
-            &request(NetworkRequest::AnyHost, false, &[])
-        ));
-        assert!(!covers(&grants, &request(NetworkRequest::None, true, &[])));
-    }
-
-    #[test]
-    fn unrestricted_writes_do_not_cover_git_access() {
-        let mut grants = ThreadSandboxGrants::default();
-        grants.record(&request(NetworkRequest::None, true, &[]));
-
-        let mut git_request = request(NetworkRequest::None, false, &[]);
-        git_request.allow_git_access = true;
-        assert!(!covers(&grants, &git_request));
-    }
-
     #[test]
     fn persistent_grants_combine_with_thread_grants() {
         let mut grants = ThreadSandboxGrants::default();
@@ -1189,7 +1138,6 @@ mod tests {
         let grants = ThreadSandboxGrants::default();
         let persistent = SandboxPermissions {
             allow_all_hosts: true,
-            allow_git_access: true,
             write_paths: vec![PathBuf::from("/tmp/always")],
             ..Default::default()
         };
@@ -1197,7 +1145,6 @@ mod tests {
         let effective = grants
             .effective_with_persistent(&request(NetworkRequest::None, false, &[]), &persistent);
         assert_eq!(effective.network, NetworkRequest::AnyHost);
-        assert!(effective.allow_git_access);
         assert_eq!(effective.write_paths, vec![PathBuf::from("/tmp/always")]);
     }
 
diff --git a/crates/agent/src/templates.rs b/crates/agent/src/templates.rs
index 582c12f4fe31e8..6b0942dca9305d 100644
--- a/crates/agent/src/templates.rs
+++ b/crates/agent/src/templates.rs
@@ -209,13 +209,12 @@ mod tests {
         assert!(rendered.contains("allow_hosts"));
         assert!(rendered.contains("allow_all_hosts: true"));
         assert!(rendered.contains("fs_write_paths"));
-        assert!(rendered.contains("allow_git_access: true"));
         assert!(rendered.contains("allow_fs_write_all: true"));
         assert!(rendered.contains("unsandboxed: true"));
-        assert!(rendered.contains("file contents under `.git` directories"));
-        assert!(
-            rendered.contains("worktree metadata that may live outside the project directories")
-        );
+        assert!(rendered.contains("`.git` directories remain protected"));
+        assert!(rendered.contains("Git metadata writes are never grantable inside the sandbox"));
+        assert!(rendered.contains("request `unsandboxed: true` with a reason"));
+        assert!(rendered.contains("git --no-optional-locks status"));
         assert!(rendered.contains("for the rest of the thread"));
     }
 
@@ -249,6 +248,37 @@ mod tests {
         assert!(rendered.contains("`/tmp/alpha`"));
     }
 
+    #[test]
+    fn test_system_prompt_windows_sandbox_section_rejects_host_specific_network() {
+        use prompt_store::{ProjectContext, WorktreeContext};
+
+        let worktrees = vec![WorktreeContext {
+            root_name: "alpha".to_string(),
+            abs_path: std::path::Path::new("C:/Users/me/project").into(),
+            rules_file: None,
+        }];
+        let project = ProjectContext::new(worktrees);
+        let template = SystemPromptTemplate {
+            project: &project,
+            available_tools: vec!["echo".into()],
+            model_name: Some("test-model".to_string()),
+            date: "2026-01-01".to_string(),
+            user_agents_md: None,
+            sandboxing: true,
+            is_linux: false,
+            is_windows: true,
+        };
+        let templates = Templates::new();
+        let rendered = template.render(&templates).unwrap();
+
+        assert!(rendered.contains("commands run inside WSL under Bubblewrap"));
+        assert!(rendered.contains("Protected Git metadata remains read-only"));
+        assert!(rendered.contains("do not use this on Windows"));
+        assert!(rendered.contains("such requests are rejected"));
+        assert!(rendered.contains("allow_all_hosts: true"));
+        assert!(rendered.contains("git --no-optional-locks status"));
+    }
+
     #[test]
     fn test_system_prompt_sandbox_section_handles_zero_worktrees() {
         let project = prompt_store::ProjectContext::default();
diff --git a/crates/agent/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs
index c615d0958fc219..160c94328d168a 100644
--- a/crates/agent/src/templates/system_prompt.hbs
+++ b/crates/agent/src/templates/system_prompt.hbs
@@ -158,13 +158,13 @@ The current project contains the following root directories:
 
 The `terminal` tool runs commands inside a sandbox with these permissions:
 
-- Reads: any path on the filesystem is readable, except that file contents under `.git` directories are blocked by default (their metadata stays visible). See `allow_git_access` below.
+- Reads: any path on the filesystem is readable, including Git metadata.
 {{#if is_linux}}
 - Writes: `/tmp` is writable but is cleared between `terminal` calls{{#if worktrees}}. These project directories are also writable and persist across calls:
 {{#each worktrees}}
   - `{{abs_path}}`
 {{/each}}
-  `.git` directories are not writable by default. Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}}
+  `.git` directories remain protected. Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}}
 {{else}}
 {{#if is_windows}}
 - Execution: commands run inside WSL under Bubblewrap. Native Windows project paths are routed through WSL's `/mnt//...` filesystem view.
@@ -172,13 +172,13 @@ The `terminal` tool runs commands inside a sandbox with these permissions:
 {{#each worktrees}}
   - `{{abs_path}}`
 {{/each}}
-  Writes anywhere else on the WSL filesystem and mounted Windows drives are blocked.{{else}}. No project directories are currently writable.{{/if}}
+  Protected Git metadata remains read-only. Writes anywhere else on the WSL filesystem and mounted Windows drives are blocked.{{else}}. No project directories are currently writable.{{/if}}
 {{else}}
 - Writes: a per-thread temporary directory exposed via `$TMPDIR`, `$TMP`, and `$TEMP` is writable and persists across `terminal` calls in this thread{{#if worktrees}}, along with these project directories:
 {{#each worktrees}}
   - `{{abs_path}}`
 {{/each}}
-  `.git` directories are not writable by default. Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}}
+  `.git` directories remain protected. Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}}
 {{/if}}
 {{/if}}
 - Network: outbound network access is blocked.
@@ -189,7 +189,7 @@ The sandbox can only allow or block outbound network access as a whole — it ca
 You can request elevated permissions on individual `terminal` calls:
 
 - `allow_all_hosts: true` — allow unrestricted outbound network access. On this platform this is the only way to grant network access.
-- `allow_hosts: ["github.com", ...]` — accepted, but on this platform listing hosts grants unrestricted outbound network access (identical to `allow_all_hosts`), because per-host restriction can't be enforced. Prefer `allow_all_hosts` so the request is explicit.
+- `allow_hosts: ["github.com", ...]` — do not use this on Windows. Host-specific network grants cannot be enforced, and such requests are rejected; use `allow_all_hosts: true` when the command genuinely needs network access.
 {{else}}
 Host-scoped network access works through an HTTP/HTTPS proxy (standard proxy environment variables are set for the command). When access is scoped to specific hosts, tools that don't honor proxy environment variables (SSH, FTP, raw sockets, etc.) can't reach them, so use `https://` URLs instead of `git@`/`ssh://` when cloning or pushing.
 
@@ -198,10 +198,11 @@ You can request elevated permissions on individual `terminal` calls:
 - `allow_hosts: ["github.com", "*.npmjs.org"]` — allow outbound HTTP/HTTPS to specific hosts (exact hostnames or leading-`*.` subdomain wildcards; no IP literals). Prefer this whenever you know which hosts the command needs.
 - `allow_all_hosts: true` — lift the network restriction entirely: outbound access to any host over any protocol, so SSH, FTP, and raw sockets work too (unlike `allow_hosts`, which is HTTP/HTTPS-only). Use only when the specific hosts can't be enumerated up front.
 {{/if}}
-- `fs_write_paths: ["/abs/or/worktree-relative/path", ...]` — allow writes to specific paths (each directory grants its whole subtree). Prefer this whenever you know which paths the command needs to write.
-- `allow_git_access: true` — lift the default block on Git metadata, allowing reads and writes of file contents under `.git` directories (including worktree metadata that may live outside the project directories). Any command that touches `.git` needs this, including ones that invoke Git only incidentally (e.g. build scripts calling `git describe`/`git rev-parse`, or commit hooks).
-- `allow_fs_write_all: true` — allow unrestricted filesystem writes. Only use this when the specific paths can't be enumerated up front.
-- `unsandboxed: true` — run the command with no sandbox at all. Use only when none of the above suffice.
+- `fs_write_paths: ["/abs/or/worktree-relative/path", ...]` — allow writes to specific paths (each directory grants its whole subtree). Prefer this whenever you know which paths the command needs to write. Git metadata paths cannot be requested and will never be made writable while sandboxed.
+- `allow_fs_write_all: true` — allow unrestricted filesystem writes except protected Git metadata. Only use this when the specific paths can't be enumerated up front.
+- `unsandboxed: true` — run the command with no sandbox at all. Use only when none of the above suffice, including when a command must write Git metadata.
+
+Git metadata writes are never grantable inside the sandbox. If a command needs to update `.git`, linked worktree metadata, refs, the index, hooks, local Git config, or other Git metadata, request `unsandboxed: true` with a reason. For read-only Git operations, prefer flags that avoid optional metadata writes where possible, such as `git --no-optional-locks status` instead of `git status`.
 
 The user will be prompted to approve before the command runs, and can grant a sandbox request for that command, for the rest of the thread, or always. Once a host or write path is granted for the thread or always, later commands in this thread reaching that host or writing under that path won't prompt again.
 
diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs
index 55e492c3b3f60c..4998a210b8a9d5 100644
--- a/crates/agent/src/tests/mod.rs
+++ b/crates/agent/src/tests/mod.rs
@@ -7147,6 +7147,12 @@ async fn test_fetch_tool_allow_rule_skips_confirmation(cx: &mut TestAppContext)
                 invalid_patterns: vec![],
             },
         );
+        // The fetch tool also gates on the shared per-host network grant, so
+        // grant docs.rs to keep this URL fully silent.
+        settings
+            .sandbox_permissions
+            .network_hosts
+            .push("docs.rs".into());
         agent_settings::AgentSettings::override_global(settings, cx);
     });
 
@@ -7166,7 +7172,181 @@ async fn test_fetch_tool_allow_rule_skips_confirmation(cx: &mut TestAppContext)
     let event = rx.try_recv();
     assert!(
         !matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))),
-        "expected no authorization request for allowed docs.rs URL"
+        "expected no authorization request for allowed and granted docs.rs URL"
+    );
+}
+
+/// A fetch to a host that hasn't been granted network access prompts for the
+/// shared per-host sandbox grant, even when the tool itself is allowed.
+#[gpui::test]
+async fn test_fetch_tool_prompts_for_ungranted_host(cx: &mut TestAppContext) {
+    init_test(cx);
+
+    cx.update(|cx| {
+        let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
+        settings.tool_permissions.tools.insert(
+            FetchTool::NAME.into(),
+            agent_settings::ToolRules {
+                default: Some(settings::ToolPermissionMode::Allow),
+                always_allow: vec![],
+                always_deny: vec![],
+                always_confirm: vec![],
+                invalid_patterns: vec![],
+            },
+        );
+        agent_settings::AgentSettings::override_global(settings, cx);
+    });
+
+    let http_client = gpui::http_client::FakeHttpClient::with_200_response();
+
+    #[allow(clippy::arc_with_non_send_sync)]
+    let tool = Arc::new(crate::FetchTool::new(http_client));
+    let (event_stream, mut rx) = crate::ToolCallEventStream::test();
+
+    let input: crate::FetchToolInput =
+        serde_json::from_value(json!({"url": "https://example.com/page"})).unwrap();
+
+    let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
+
+    cx.run_until_parked();
+
+    let authorization = rx.expect_authorization().await;
+    let details =
+        acp_thread::sandbox_authorization_details_from_meta(&authorization.tool_call.meta)
+            .expect("an ungranted host should request a sandbox network grant");
+    assert_eq!(details.network_hosts, vec!["example.com".to_string()]);
+    assert!(!details.network_all_hosts);
+}
+
+/// A host already present in the shared sandbox grants lets a fetch proceed
+/// without any prompt — the same grant the terminal tool records and consults.
+#[gpui::test]
+async fn test_fetch_tool_granted_host_skips_prompt(cx: &mut TestAppContext) {
+    init_test(cx);
+
+    cx.update(|cx| {
+        let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
+        // Allow the tool itself so only the shared per-host grant is under test.
+        settings.tool_permissions.tools.insert(
+            FetchTool::NAME.into(),
+            agent_settings::ToolRules {
+                default: Some(settings::ToolPermissionMode::Allow),
+                always_allow: vec![],
+                always_deny: vec![],
+                always_confirm: vec![],
+                invalid_patterns: vec![],
+            },
+        );
+        settings
+            .sandbox_permissions
+            .network_hosts
+            .push("example.com".into());
+        agent_settings::AgentSettings::override_global(settings, cx);
+    });
+
+    let http_client = gpui::http_client::FakeHttpClient::with_200_response();
+
+    #[allow(clippy::arc_with_non_send_sync)]
+    let tool = Arc::new(crate::FetchTool::new(http_client));
+    let (event_stream, mut rx) = crate::ToolCallEventStream::test();
+
+    let input: crate::FetchToolInput =
+        serde_json::from_value(json!({"url": "https://example.com/page"})).unwrap();
+
+    let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
+
+    cx.run_until_parked();
+
+    let event = rx.try_recv();
+    assert!(
+        !matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))),
+        "expected no authorization request for an already-granted host"
+    );
+}
+
+/// Loopback / IP-literal hosts can't be granted individually, so without
+/// unsandboxed access a fetch to them is refused with guidance to grant it.
+#[gpui::test]
+async fn test_fetch_tool_refuses_loopback_without_unsandboxed(cx: &mut TestAppContext) {
+    init_test(cx);
+
+    cx.update(|cx| {
+        let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
+        // Allow the tool itself so the request reaches the per-host gate.
+        settings.tool_permissions.tools.insert(
+            FetchTool::NAME.into(),
+            agent_settings::ToolRules {
+                default: Some(settings::ToolPermissionMode::Allow),
+                always_allow: vec![],
+                always_deny: vec![],
+                always_confirm: vec![],
+                invalid_patterns: vec![],
+            },
+        );
+        agent_settings::AgentSettings::override_global(settings, cx);
+    });
+
+    let http_client = gpui::http_client::FakeHttpClient::with_200_response();
+
+    #[allow(clippy::arc_with_non_send_sync)]
+    let tool = Arc::new(crate::FetchTool::new(http_client));
+    let (event_stream, _rx) = crate::ToolCallEventStream::test();
+
+    let input: crate::FetchToolInput =
+        serde_json::from_value(json!({"url": "http://localhost:3000/api"})).unwrap();
+
+    let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
+    let result = task.await;
+    assert!(result.is_err(), "expected a loopback fetch to be refused");
+    assert!(
+        result.unwrap_err().contains("unsandboxed"),
+        "error should point at unsandboxed access as the way to reach loopback hosts"
+    );
+}
+
+/// Granting unsandboxed access lifts every fetch restriction, matching the
+/// terminal: even loopback hosts become reachable and no per-host prompt is
+/// requested.
+#[gpui::test]
+async fn test_fetch_tool_unsandboxed_lifts_restrictions(cx: &mut TestAppContext) {
+    init_test(cx);
+
+    cx.update(|cx| {
+        let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
+        settings.sandbox_permissions.allow_unsandboxed = true;
+        // Allow the tool itself so only the per-host gate is under test.
+        settings.tool_permissions.tools.insert(
+            FetchTool::NAME.into(),
+            agent_settings::ToolRules {
+                default: Some(settings::ToolPermissionMode::Allow),
+                always_allow: vec![],
+                always_deny: vec![],
+                always_confirm: vec![],
+                invalid_patterns: vec![],
+            },
+        );
+        agent_settings::AgentSettings::override_global(settings, cx);
+    });
+
+    let http_client = gpui::http_client::FakeHttpClient::with_200_response();
+
+    #[allow(clippy::arc_with_non_send_sync)]
+    let tool = Arc::new(crate::FetchTool::new(http_client));
+    let (event_stream, mut rx) = crate::ToolCallEventStream::test();
+
+    // A loopback host that could never be granted individually is reachable,
+    // and no per-host authorization is requested.
+    let input: crate::FetchToolInput =
+        serde_json::from_value(json!({"url": "http://localhost:3000/api"})).unwrap();
+
+    let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
+
+    cx.run_until_parked();
+
+    let event = rx.try_recv();
+    assert!(
+        !matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))),
+        "expected no authorization request when unsandboxed access is granted"
     );
 }
 
diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs
index 6bc2715dd7e652..fb2b277a634b5c 100644
--- a/crates/agent/src/thread.rs
+++ b/crates/agent/src/thread.rs
@@ -12,10 +12,10 @@ use action_log::ActionLog;
 use agent_settings::UserAgentsMd;
 
 use crate::sandboxing::{
-    SandboxRequest, ThreadSandbox, ThreadSandboxGrants, sandboxing_available_for_project,
+    SandboxRequest, ThreadSandbox, ThreadSandboxGrants, sandbox_git_dirs,
+    sandbox_worktree_writable_paths, sandboxing_available_for_project,
     sandboxing_enabled_for_project,
 };
-use crate::tools::{SandboxGitPathCandidates, sandbox_git_paths};
 use agent_client_protocol::schema::v1 as acp;
 use agent_settings::{
     AgentProfileId, AgentProfileSettings, AgentSettings, AutoCompactThreshold, COMPACTION_PROMPT,
@@ -99,9 +99,6 @@ pub struct SandboxStatusKey {
     pub thread_sandbox: ThreadSandbox,
     pub baseline_writable_paths: Vec,
     pub git_paths: Vec,
-    pub repository_paths: Vec<(PathBuf, PathBuf, PathBuf, PathBuf)>,
-    pub settings_allow_git_access: bool,
-    pub thread_allow_git_access: bool,
 }
 
 #[derive(Clone, Debug, Eq, PartialEq)]
@@ -1794,21 +1791,16 @@ impl Thread {
         }
     }
 
-    /// The sandbox grants configured for this thread, using unverified Git path
-    /// candidates. Use [`Self::refresh_verified_sandbox_status`] for UI or other
-    /// surfaces that need to match terminal enforcement.
     pub fn sandbox_status(&self, cx: &App) -> Option<(ThreadSandbox, ThreadSandbox)> {
         if !self.sandboxing_available(cx) {
             return None;
         }
         let persistent = AgentSettings::get_global(cx).sandbox_permissions.clone();
-        let git_dirs = crate::sandboxing::sandbox_git_dirs(self.project.read(cx), cx);
+        let git_dirs = sandbox_git_dirs(self.project.read(cx), cx);
         let grants = self.sandbox_grants.borrow();
         let settings = crate::sandboxing::settings_thread_sandbox(&persistent)
-            .with_git(persistent.allow_git_access, git_dirs.clone());
-        let thread = grants
-            .thread_sandbox()
-            .with_git(grants.git_access_granted(), git_dirs);
+            .with_protected_paths(git_dirs.clone());
+        let thread = grants.thread_sandbox().with_protected_paths(git_dirs);
         Some((settings, thread))
     }
 
@@ -1824,69 +1816,27 @@ impl Thread {
         let settings_sandbox = crate::sandboxing::settings_thread_sandbox(&persistent);
         let grants = self.sandbox_grants.borrow();
         let thread_sandbox = grants.thread_sandbox();
-        let thread_allow_git_access = grants.git_access_granted();
         drop(grants);
 
-        let (sandbox_path_candidates, fs) = {
-            let project = self.project.read(cx);
-            (
-                SandboxGitPathCandidates::from_project(project, cx),
-                project.fs().clone(),
-            )
-        };
-        let baseline_writable_paths = sandbox_path_candidates.writable_paths.clone();
-        let git_paths = sandbox_path_candidates.git_paths.clone();
-        let repository_paths = sandbox_path_candidates.cache_key_repositories();
+        let project = self.project.read(cx);
+        let baseline_writable_paths = sandbox_worktree_writable_paths(project, cx);
+        let git_paths = sandbox_git_dirs(project, cx);
 
         let key = SandboxStatusKey {
             settings_sandbox: settings_sandbox.clone(),
             thread_sandbox: thread_sandbox.clone(),
             baseline_writable_paths: baseline_writable_paths.clone(),
             git_paths: git_paths.clone(),
-            repository_paths,
-            settings_allow_git_access: persistent.allow_git_access,
-            thread_allow_git_access,
         };
 
-        if settings_sandbox.is_unsandboxed() || thread_sandbox.is_unsandboxed() {
-            return Some((
-                key,
-                SandboxStatusRefresh::Ready(VerifiedSandboxStatus {
-                    settings_sandbox,
-                    thread_sandbox,
-                    baseline_writable_paths,
-                }),
-            ));
-        }
-
-        let git_access_requested = persistent.allow_git_access || thread_allow_git_access;
-        if !git_access_requested {
-            return Some((
-                key,
-                SandboxStatusRefresh::Ready(VerifiedSandboxStatus {
-                    settings_sandbox: settings_sandbox.with_git(false, git_paths.clone()),
-                    thread_sandbox: thread_sandbox.with_git(false, git_paths),
-                    baseline_writable_paths,
-                }),
-            ));
-        }
-
-        let task = cx.spawn(async move |_this, _cx| {
-            let sandbox_paths = sandbox_git_paths(sandbox_path_candidates, fs.as_ref(), true).await;
-            VerifiedSandboxStatus {
-                settings_sandbox: settings_sandbox.with_git(
-                    persistent.allow_git_access && sandbox_paths.allow_git_access,
-                    sandbox_paths.git_dirs.clone(),
-                ),
-                thread_sandbox: thread_sandbox.with_git(
-                    thread_allow_git_access && sandbox_paths.allow_git_access,
-                    sandbox_paths.git_dirs,
-                ),
+        Some((
+            key,
+            SandboxStatusRefresh::Ready(VerifiedSandboxStatus {
+                settings_sandbox: settings_sandbox.with_protected_paths(git_paths.clone()),
+                thread_sandbox: thread_sandbox.with_protected_paths(git_paths),
                 baseline_writable_paths,
-            }
-        });
-
-        Some((key, SandboxStatusRefresh::Pending(task)))
+            }),
+        ))
     }
 
     /// Whether agent terminal commands are sandboxed for this thread's project,
@@ -5650,7 +5600,6 @@ impl ToolCallEventStream {
             command: None,
             network_hosts,
             network_all_hosts,
-            allow_git_access: request.allow_git_access,
             allow_fs_write_all: request.allow_fs_write_all,
             unsandboxed: request.unsandboxed,
             write_paths: request.write_paths.clone(),
@@ -5855,9 +5804,7 @@ impl ToolCallEventStream {
                         agent.set_sandbox_network_hosts(host_strings);
                     }
                 }
-                if request.allow_git_access {
-                    agent.allow_sandbox_git_access();
-                }
+
                 if request.allow_fs_write_all {
                     agent.allow_sandbox_fs_write_all();
                 }
@@ -5901,6 +5848,20 @@ impl ToolCallEventStream {
         self.sandbox_grants.borrow().unsandboxed_granted()
     }
 
+    /// Whether unsandboxed access is currently in effect: granted for this
+    /// thread (a model-requested `unsandboxed` escape or a sandbox-creation
+    /// fallback) or configured persistently via `allow_unsandboxed`. When true,
+    /// commands already run without any OS sandbox, so per-host network grants
+    /// no longer provide isolation and callers may skip host authorization
+    /// entirely.
+    pub(crate) fn unsandboxed_access_granted(&self, cx: &App) -> bool {
+        self.unsandboxed_granted_for_thread()
+            || self.sandbox_fallback_granted_for_thread()
+            || AgentSettings::get_global(cx)
+                .sandbox_permissions
+                .allow_unsandboxed
+    }
+
     /// Ask the user how to proceed when the OS sandbox could not be created
     /// for a command (for example, `bwrap` is missing or user namespaces are
     /// disabled).
@@ -7592,7 +7553,6 @@ mod tests {
         let (event_stream, mut receiver) = ToolCallEventStream::test();
         let request = SandboxRequest {
             network: crate::sandboxing::NetworkRequest::None,
-            allow_git_access: false,
             allow_fs_write_all: false,
             unsandboxed: false,
             write_paths: vec![
@@ -7616,7 +7576,6 @@ mod tests {
                 .expect("sandbox authorization should include request details");
         assert!(details.network_hosts.is_empty());
         assert!(!details.network_all_hosts);
-        assert_eq!(details.allow_git_access, request.allow_git_access);
         assert_eq!(details.allow_fs_write_all, request.allow_fs_write_all);
         assert_eq!(details.unsandboxed, request.unsandboxed);
         assert_eq!(details.write_paths, request.write_paths);
diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs
index f55c16ae931929..452b4a01d09135 100644
--- a/crates/agent/src/tools.rs
+++ b/crates/agent/src/tools.rs
@@ -84,7 +84,7 @@ pub use rename_tool::*;
 pub use skill_tool::*;
 pub use spawn_agent_tool::*;
 pub use symbol_locator::*;
-pub(crate) use terminal_tool::sandbox_git_paths::{SandboxGitPathCandidates, sandbox_git_paths};
+
 pub use terminal_tool::*;
 pub use tool_permissions::*;
 pub use web_search_tool::*;
diff --git a/crates/agent/src/tools/fetch_tool.rs b/crates/agent/src/tools/fetch_tool.rs
index 5d6cc82dbba2b3..96c0fd2aba175b 100644
--- a/crates/agent/src/tools/fetch_tool.rs
+++ b/crates/agent/src/tools/fetch_tool.rs
@@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize};
 use ui::SharedString;
 use util::markdown::{MarkdownEscaped, MarkdownInlineCode};
 
+use crate::sandboxing::{NetworkRequest, SandboxRequest};
 use crate::{AgentTool, ToolCallEventStream, ToolInput};
 
 #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
@@ -23,6 +24,14 @@ enum ContentType {
 }
 
 /// Fetches a URL and returns the content as Markdown.
+///
+/// This tool is not run inside the terminal OS sandbox, but it still refuses to
+/// reach any host that hasn't been granted network access. It shares the same
+/// per-host grants as the `terminal` tool: approving a host for one authorizes
+/// it for the other, whether the grant is for this thread or saved permanently.
+/// When unsandboxed access has been granted, these restrictions are lifted
+/// entirely, matching the terminal, which is also how loopback and IP-literal
+/// hosts (which can't be granted individually) become reachable.
 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
 pub struct FetchToolInput {
     /// The URL to fetch.
@@ -114,6 +123,30 @@ impl FetchTool {
     }
 }
 
+/// Extracts the host from a fetch URL as a [`http_proxy::HostPattern`] so it can
+/// be matched against the shared network grants. Mirrors the scheme handling in
+/// [`FetchTool::build_message`] (defaulting to `https://` when none is given).
+fn host_pattern_for_url(url: &str) -> Result {
+    let normalized = if !url.starts_with("https://") && !url.starts_with("http://") {
+        Cow::Owned(format!("https://{url}"))
+    } else {
+        Cow::Borrowed(url)
+    };
+    let parsed =
+        url::Url::parse(&normalized).with_context(|| format!("could not parse URL {url:?}"))?;
+    let host = parsed
+        .host_str()
+        .with_context(|| format!("URL {url:?} has no host to authorize network access for"))?;
+    http_proxy::HostPattern::parse(host).map_err(|error| match error {
+        http_proxy::HostPatternError::IpLiteral(_) => anyhow::anyhow!(
+            "cannot fetch {host:?}: loopback and IP-literal hosts can't be granted network \
+             access individually. They are only reachable once unsandboxed access has been \
+             granted (for example, via a terminal command that requests it)."
+        ),
+        error => anyhow::anyhow!("cannot authorize network access to {host:?}: {error}"),
+    })
+}
+
 impl AgentTool for FetchTool {
     type Input = FetchToolInput;
     type Output = String;
@@ -149,6 +182,8 @@ impl AgentTool for FetchTool {
         cx.spawn(async move |cx| {
             let input: FetchToolInput = input.recv().await.map_err(|e| e.to_string())?;
 
+            // First, the standard tool-permission gate (honors the fetch tool's
+            // allow/deny/confirm rules).
             let authorize = cx.update(|cx| {
                 let context =
                     crate::ToolPermissionContext::new(Self::NAME, vec![input.url.clone()]);
@@ -159,14 +194,45 @@ impl AgentTool for FetchTool {
                     cx,
                 )
             });
+            futures::select! {
+                result = authorize.fuse() => result.map_err(|e| e.to_string())?,
+                _ = event_stream.cancelled_by_user().fuse() => {
+                    return Err("Fetch cancelled by user".to_string());
+                }
+            };
+
+            // Then, unless unsandboxed access is already in effect, the per-host
+            // network grant shared with the terminal tool. If the host isn't
+            // already granted (for this thread or in saved settings) the user is
+            // shown the same escalation prompt the terminal uses; a denial
+            // aborts the fetch. This tool never runs inside the OS sandbox, so
+            // the grant is only consulted to decide whether the request may
+            // proceed. When unsandboxed access has been granted the terminal
+            // already runs without isolation, so we drop fetch's restrictions
+            // too — including reaching hosts that can't be granted individually
+            // (loopback and IP literals).
+            let unsandboxed = cx.update(|cx| event_stream.unsandboxed_access_granted(cx));
+            if !unsandboxed {
+                let host = host_pattern_for_url(&input.url).map_err(|e| e.to_string())?;
+                let authorize_host = cx.update(|cx| {
+                    let request = SandboxRequest {
+                        network: NetworkRequest::Hosts(vec![host]),
+                        ..Default::default()
+                    };
+                    event_stream.authorize_sandbox(request, String::new(), cx)
+                });
+                futures::select! {
+                    result = authorize_host.fuse() => result.map_err(|e| e.to_string())?,
+                    _ = event_stream.cancelled_by_user().fuse() => {
+                        return Err("Fetch cancelled by user".to_string());
+                    }
+                };
+            }
 
             let fetch_task = cx.background_spawn({
                 let http_client = http_client.clone();
                 let url = input.url.clone();
-                async move {
-                    authorize.await?;
-                    Self::build_message(http_client, &url).await
-                }
+                async move { Self::build_message(http_client, &url).await }
             });
 
             let text = futures::select! {
diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs
index 9dba295126491d..ef394c5e8ba14d 100644
--- a/crates/agent/src/tools/terminal_tool.rs
+++ b/crates/agent/src/tools/terminal_tool.rs
@@ -15,11 +15,11 @@ use std::{
 
 #[cfg(any(target_os = "linux", target_os = "windows"))]
 use crate::SandboxFallbackDecision;
-use crate::sandboxing::{NetworkRequest, sandboxing_enabled_for_project};
+use crate::sandboxing::{
+    NetworkRequest, sandbox_git_dirs, sandbox_worktree_writable_paths,
+    sandboxing_enabled_for_project,
+};
 use crate::{AgentTool, ThreadEnvironment, ToolCallEventStream, ToolInput};
-use sandbox_git_paths::{SandboxGitPathCandidates, sandbox_git_paths};
-
-pub(crate) mod sandbox_git_paths;
 
 const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024;
 
@@ -44,13 +44,14 @@ const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024;
 /// The terminal is an interactive pty, so any command that blocks waiting for input will hang the tool until it times out. To avoid this:
 ///
 /// - Always insert `--no-pager` immediately after `git` for any read-only git command, including `git log`, `git diff`, `git show`, `git blame`, and `git stash show`. Example: `git --no-pager log -n 5` (NOT `git log -n 5`).
+/// - Prefer Git flags that avoid optional metadata writes when possible, such as `git --no-optional-locks status` instead of `git status`.
 /// - Always prepend `GIT_EDITOR=true ` to any git command that may invoke an editor, including `git rebase`, `git commit`, `git merge`, and `git tag`. Example: `GIT_EDITOR=true git rebase origin/main` (NOT `git rebase origin/main`).
 /// - For other commands that may open a pager or editor, set `PAGER=cat` and/or `EDITOR=true` similarly.
 #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
 pub struct TerminalToolInput {
     /// The one-liner command to execute. Do not include shell substitutions or interpolations such as `$VAR`, `${VAR}`, `$(...)`, backticks, `$((...))`, `<(...)`, or `>(...)`; resolve those values first or ask the user for the literal value to use.
     ///
-    /// REMINDER: read-only git commands (`git log`, `git diff`, `git show`, `git blame`) MUST include `--no-pager` (e.g. `git --no-pager log`). Git commands that may open an editor (`git rebase`, `git commit`, `git merge`, `git tag`) MUST be prefixed with `GIT_EDITOR=true ` (e.g. `GIT_EDITOR=true git rebase origin/main`). Otherwise the terminal will hang.
+    /// REMINDER: read-only git commands (`git log`, `git diff`, `git show`, `git blame`) MUST include `--no-pager` (e.g. `git --no-pager log`). Prefer `git --no-optional-locks status` over `git status` to avoid optional metadata writes. Git commands that may open an editor (`git rebase`, `git commit`, `git merge`, `git tag`) MUST be prefixed with `GIT_EDITOR=true ` (e.g. `GIT_EDITOR=true git rebase origin/main`). Otherwise the terminal will hang.
     pub command: String,
     /// Working directory for the command. This must be one of the root directories of the project.
     pub cd: String,
@@ -85,13 +86,14 @@ pub struct TerminalToolInput {
 /// The terminal is an interactive pty, so any command that blocks waiting for input will hang the tool until it times out. To avoid this:
 ///
 /// - Always insert `--no-pager` immediately after `git` for any read-only git command, including `git log`, `git diff`, `git show`, `git blame`, and `git stash show`. Example: `git --no-pager log -n 5` (NOT `git log -n 5`).
+/// - Prefer Git flags that avoid optional metadata writes when possible, such as `git --no-optional-locks status` instead of `git status`.
 /// - Always prepend `GIT_EDITOR=true ` to any git command that may invoke an editor, including `git rebase`, `git commit`, `git merge`, and `git tag`. Example: `GIT_EDITOR=true git rebase origin/main` (NOT `git rebase origin/main`).
 /// - For other commands that may open a pager or editor, set `PAGER=cat` and/or `EDITOR=true` similarly.
 #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
 pub struct SandboxedTerminalToolInput {
     /// The one-liner command to execute. Do not include shell substitutions or interpolations such as `$VAR`, `${VAR}`, `$(...)`, backticks, `$((...))`, `<(...)`, or `>(...)`; resolve those values first or ask the user for the literal value to use.
     ///
-    /// REMINDER: read-only git commands (`git log`, `git diff`, `git show`, `git blame`) MUST include `--no-pager` (e.g. `git --no-pager log`). Git commands that may open an editor (`git rebase`, `git commit`, `git merge`, `git tag`) MUST be prefixed with `GIT_EDITOR=true ` (e.g. `GIT_EDITOR=true git rebase origin/main`). Otherwise the terminal will hang.
+    /// REMINDER: read-only git commands (`git log`, `git diff`, `git show`, `git blame`) MUST include `--no-pager` (e.g. `git --no-pager log`). Prefer `git --no-optional-locks status` over `git status` to avoid optional metadata writes. Git commands that may open an editor (`git rebase`, `git commit`, `git merge`, `git tag`) MUST be prefixed with `GIT_EDITOR=true ` (e.g. `GIT_EDITOR=true git rebase origin/main`). Otherwise the terminal will hang.
     pub command: String,
     /// Working directory for the command. This must be one of the root directories of the project.
     pub cd: String,
@@ -129,9 +131,10 @@ pub struct SandboxedTerminalToolInput {
     /// Set to `true` only if the command needs outbound network access to
     /// hosts you can't enumerate up front.
     ///
-    /// This grants unrestricted outbound network access. Prefer `allow_hosts`
-    /// with specific hostnames whenever possible, so the user knows what's
-    /// being approved. Requesting it triggers a user approval prompt.
+    /// This grants unrestricted outbound network access. On platforms that
+    /// support host-specific grants, prefer `allow_hosts` with specific
+    /// hostnames whenever possible, so the user knows what's being approved.
+    /// Requesting it triggers a user approval prompt.
     #[serde(default)]
     pub allow_all_hosts: Option,
     /// Paths the command needs to write to outside the default-writable
@@ -146,7 +149,8 @@ pub struct SandboxedTerminalToolInput {
     /// Provide absolute or worktree-relative paths; each
     /// directory grants write access to its whole subtree. Prefer this over
     /// `allow_fs_write_all` whenever you can enumerate the paths. Requesting
-    /// paths triggers a user approval prompt.
+    /// paths triggers a user approval prompt. Git metadata paths cannot be
+    /// requested and will never be made writable while sandboxed.
     #[cfg_attr(
         target_os = "linux",
         doc = "\nOn Linux, every path here must be a directory that already exists. \
@@ -160,25 +164,18 @@ pub struct SandboxedTerminalToolInput {
     /// enumerated up front.
     ///
     /// This is a broad escape hatch — prefer `fs_write_paths` whenever the
-    /// set of paths is known. Requesting it triggers a user approval prompt.
+    /// set of paths is known. Protected Git metadata remains read-only.
+    /// Requesting it triggers a user approval prompt.
     #[serde(default, alias = "allow_fs_write")]
     pub allow_fs_write_all: Option,
-    /// Set to `true` when the command needs access to protected Git metadata.
-    ///
-    /// By default sandboxed commands can't write to the `.git` directories of
-    /// opened worktrees and discovered repositories. On macOS, `.git` file
-    /// contents are also hidden while metadata stays visible; on Linux and
-    /// Windows/WSL, `.git` contents remain readable but are mounted read-only.
-    /// Set this for Git operations that need to write those paths (commit,
-    /// fetch, rebase, …). Requesting it triggers a user approval prompt.
-    #[serde(default)]
-    pub allow_git_access: Option,
+
     /// Set to `true` only as a last resort, to run the command fully outside
     /// the sandbox.
     ///
-    /// First try the narrower options (`allow_hosts`, `fs_write_paths`,
-    /// `allow_fs_write_all`, `allow_git_access`); use this only when the command
-    /// needs behavior the sandbox can't grant on a per-permission basis.
+    /// First try the narrower options (`allow_hosts`, `fs_write_paths`, or
+    /// `allow_fs_write_all`); use this only when the command
+    /// needs behavior the sandbox can't grant on a per-permission basis,
+    /// including commands that must write Git metadata.
     /// Requesting it triggers a user approval prompt.
     #[cfg_attr(
         target_os = "windows",
@@ -192,8 +189,8 @@ pub struct SandboxedTerminalToolInput {
     #[serde(default)]
     pub unsandboxed: Option,
     /// A short justification for why this command needs the sandbox
-    /// permission(s) it requests (`allow_network`, `fs_write_paths`,
-    /// `allow_fs_write_all`, or `unsandboxed`).
+    /// permission(s) it requests (`allow_hosts`, `allow_all_hosts`,
+    /// `fs_write_paths`, `allow_fs_write_all`, or `unsandboxed`).
     ///
     /// Required whenever you request any of those permissions; omit it for
     /// ordinary commands that request none. Write it in your own voice — it
@@ -209,7 +206,6 @@ struct TerminalSandboxInput {
     allow_all_hosts: Option,
     fs_write_paths: Vec,
     allow_fs_write_all: Option,
-    allow_git_access: Option,
     unsandboxed: Option,
     reason: Option,
 }
@@ -252,7 +248,6 @@ impl From for TerminalToolRequest {
                 allow_all_hosts: input.allow_all_hosts,
                 fs_write_paths: input.fs_write_paths,
                 allow_fs_write_all: input.allow_fs_write_all,
-                allow_git_access: input.allow_git_access,
                 unsandboxed: input.unsandboxed,
                 reason: input.reason,
             }),
@@ -382,14 +377,19 @@ fn terminal_initial_title(input: Result) -> SharedStr
 
 /// Windows only: resolve the `(release channel, version)` of the Linux `zed` to
 /// provision inside WSL as the sandbox helper. Dev (source) builds have no
-/// matching release, so they pull the latest nightly; every other channel pins
-/// its exact running version (stripped of pre-release/build metadata, which the
-/// release API doesn't key on).
+/// matching release, so they pull the latest nightly. Nightly builds also track
+/// `latest`: nightly assets are keyed by their full build metadata
+/// (`X.Y.Z+nightly..`), which `AppVersion` strips, so a bare `X.Y.Z`
+/// never resolves on the nightly host. Preview and stable pin their exact
+/// running version (stripped of pre-release/build metadata, which the release
+/// API doesn't key on).
 #[cfg(target_os = "windows")]
 fn wsl_zed_release(cx: &App) -> Option<(String, String)> {
     use release_channel::{AppVersion, ReleaseChannel};
     match *release_channel::RELEASE_CHANNEL {
-        ReleaseChannel::Dev => Some(("nightly".to_string(), "latest".to_string())),
+        ReleaseChannel::Dev | ReleaseChannel::Nightly => {
+            Some(("nightly".to_string(), "latest".to_string()))
+        }
         channel => {
             let version = AppVersion::global(cx);
             Some((
@@ -442,7 +442,6 @@ async fn run_terminal_tool(
     let want_fs_write_all = sandboxing && sandbox_input.allow_fs_write_all == Some(true);
     let want_unsandboxed = sandboxing && sandbox_input.unsandboxed == Some(true);
     let want_all_hosts = sandboxing && sandbox_input.allow_all_hosts == Some(true);
-    let want_git_access = sandboxing && sandbox_input.allow_git_access == Some(true);
 
     let persistent = cx.update(|cx| {
         agent_settings::AgentSettings::get_global(cx)
@@ -498,8 +497,8 @@ async fn run_terminal_tool(
             {
                 return Err(
                     "Unrestricted filesystem writes are enabled for this thread, so every command \
-                     can already write anywhere; `fs_write_paths` cannot narrow that. Remove \
-                     `fs_write_paths`."
+                     can already write anywhere except protected Git metadata; `fs_write_paths` \
+                     cannot narrow that. Remove `fs_write_paths`."
                         .to_string(),
                 );
             }
@@ -569,7 +568,6 @@ async fn run_terminal_tool(
 
     let request = crate::sandboxing::SandboxRequest {
         network,
-        allow_git_access: !want_unsandboxed && want_git_access,
         allow_fs_write_all: !want_unsandboxed && want_fs_write_all,
         unsandboxed: want_unsandboxed,
         write_paths,
@@ -618,7 +616,7 @@ async fn run_terminal_tool(
         allow(unused_mut)
     )]
     let mut sandbox_not_applied: Option = None;
-    let mut git_access_downgrade_note = None;
+
     let sandbox_wrap = if sandboxing && !want_unsandboxed {
         if unsandboxed_floor {
             // Every command in this thread runs unsandboxed because the user
@@ -635,32 +633,16 @@ async fn run_terminal_tool(
                         .to_string(),
                 );
             }
-            let (fs, sandbox_path_candidates) = cx.update(|cx| {
+            let (writable_paths, protected_paths) = cx.update(|cx| {
                 (
-                    project.read(cx).fs().clone(),
-                    SandboxGitPathCandidates::from_project(project.read(cx), cx),
+                    sandbox_worktree_writable_paths(project.read(cx), cx),
+                    sandbox_git_dirs(project.read(cx), cx),
                 )
             });
-            let sandbox_paths = sandbox_git_paths(
-                sandbox_path_candidates,
-                fs.as_ref(),
-                effective.allow_git_access,
-            )
-            .await;
-            if effective.allow_git_access && !sandbox_paths.allow_git_access {
-                log::warn!(
-                    "Downgrading requested agent terminal Git metadata access because one or more external Git metadata paths could not be verified"
-                );
-                git_access_downgrade_note = Some(
-                    "Note: Git metadata access was requested or already allowed, but Zed could not verify one or more external Git metadata paths for this project. The command ran with Git metadata protected, so Git operations that read or write `.git` may fail with sandbox permission errors."
-                        .to_string(),
-                );
-            }
             let wrap = acp_thread::SandboxWrap {
-                writable_paths: sandbox_paths.writable_paths,
+                writable_paths,
                 extra_write_paths: effective.write_paths,
-                git_dirs: sandbox_paths.git_dirs,
-                allow_git_access: sandbox_paths.allow_git_access,
+                protected_paths,
                 network: network_request_to_sandbox_network_access(&effective.network),
                 allow_fs_write: effective.allow_fs_write_all,
                 is_local: is_local_project,
@@ -944,13 +926,7 @@ async fn run_terminal_tool(
     let output = terminal.current_output(cx).map_err(|e| e.to_string())?;
 
     let result = process_content(output, &input.command, timed_out, user_stopped, selection);
-    let git_access_downgrade_note = (sandbox_wrap.is_some() && sandbox_not_applied.is_none())
-        .then_some(git_access_downgrade_note)
-        .flatten();
-    let notes = sandbox_note
-        .into_iter()
-        .chain(git_access_downgrade_note)
-        .collect::>();
+    let notes = sandbox_note.into_iter().collect::>();
     Ok(if notes.is_empty() {
         result
     } else {
diff --git a/crates/agent/src/tools/terminal_tool/sandbox_git_paths.rs b/crates/agent/src/tools/terminal_tool/sandbox_git_paths.rs
deleted file mode 100644
index 4133d5d15cefc5..00000000000000
--- a/crates/agent/src/tools/terminal_tool/sandbox_git_paths.rs
+++ /dev/null
@@ -1,1416 +0,0 @@
-use fs::Fs;
-use gpui::App;
-use project::Project;
-use std::path::{Path, PathBuf};
-
-#[derive(Default)]
-pub(crate) struct SandboxGitPathCandidates {
-    pub(crate) writable_paths: Vec,
-    pub(crate) git_paths: Vec,
-    repositories: Vec,
-}
-
-struct SandboxGitRepositoryPaths {
-    work_directory_abs_path: PathBuf,
-    dot_git_abs_path: PathBuf,
-    repository_dir_abs_path: PathBuf,
-    common_dir_abs_path: PathBuf,
-}
-
-pub(crate) struct SandboxGitPaths {
-    pub(crate) writable_paths: Vec,
-    pub(crate) git_dirs: Vec,
-    pub(crate) allow_git_access: bool,
-}
-
-impl SandboxGitPathCandidates {
-    pub(crate) fn cache_key_repositories(&self) -> Vec<(PathBuf, PathBuf, PathBuf, PathBuf)> {
-        let mut repositories = self
-            .repositories
-            .iter()
-            .map(|repository| {
-                (
-                    repository.work_directory_abs_path.clone(),
-                    repository.dot_git_abs_path.clone(),
-                    repository.repository_dir_abs_path.clone(),
-                    repository.common_dir_abs_path.clone(),
-                )
-            })
-            .collect::>();
-        repositories.sort();
-        repositories
-    }
-
-    pub(crate) fn from_project(project: &Project, cx: &App) -> Self {
-        let mut candidates = Self::default();
-
-        for worktree in project.worktrees(cx) {
-            let worktree = worktree.read(cx);
-            let worktree_abs_path = worktree.abs_path();
-            candidates
-                .writable_paths
-                .push(worktree_abs_path.to_path_buf());
-            // Protect `/.git` even when it doesn't exist yet, so a command
-            // can't `git init` and then write to the freshly created metadata.
-            candidates.git_paths.push(worktree_abs_path.join(".git"));
-
-            // `Worktree` derefs to `Snapshot`; read the field directly instead of
-            // cloning the whole snapshot just for this path.
-            if let Some(root_repo_common_dir) = worktree.root_repo_common_dir() {
-                candidates
-                    .git_paths
-                    .push(root_repo_common_dir.to_path_buf());
-            }
-        }
-
-        // `Repository` derefs to `RepositorySnapshot`, so read the few path fields
-        // directly rather than cloning the entire snapshot (which carries the
-        // per-path status tree) for each repository.
-        for repository in project.git_store().read(cx).repositories().values() {
-            let repository = repository.read(cx);
-            let repository_paths = SandboxGitRepositoryPaths {
-                work_directory_abs_path: repository.work_directory_abs_path.to_path_buf(),
-                dot_git_abs_path: repository.dot_git_abs_path.to_path_buf(),
-                repository_dir_abs_path: repository.repository_dir_abs_path.to_path_buf(),
-                common_dir_abs_path: repository.common_dir_abs_path.to_path_buf(),
-            };
-            candidates
-                .git_paths
-                .push(repository_paths.dot_git_abs_path.clone());
-            candidates
-                .git_paths
-                .push(repository_paths.repository_dir_abs_path.clone());
-            candidates
-                .git_paths
-                .push(repository_paths.common_dir_abs_path.clone());
-            candidates.repositories.push(repository_paths);
-        }
-
-        candidates.git_paths.sort();
-        candidates.git_paths.dedup();
-        candidates.writable_paths.sort();
-        candidates.writable_paths.dedup();
-
-        candidates
-    }
-}
-
-pub(crate) async fn sandbox_git_paths(
-    candidates: SandboxGitPathCandidates,
-    fs: &dyn Fs,
-    allow_git_access: bool,
-) -> SandboxGitPaths {
-    let mut writable_paths = candidates.writable_paths;
-    let mut git_dirs = candidates.git_paths;
-
-    let mut allow_verified_git_access = false;
-    if allow_git_access {
-        let mut verified_git_paths = Vec::new();
-        for repository in candidates.repositories {
-            verified_git_paths.extend(verified_sandbox_git_paths(repository, fs).await);
-        }
-        verified_git_paths.sort();
-        verified_git_paths.dedup();
-
-        // A Git path inside a writable worktree root is already writable, so
-        // granting it can never escalate access beyond the project. A non-repo
-        // `.git` placeholder there (a plain folder opened alongside a repo, or a
-        // not-yet-initialized repo) would never appear in `verified_git_paths`,
-        // so requiring it to verify would wrongly deny the whole grant. Only
-        // paths that fall *outside* every writable root can leak access to
-        // unrelated metadata, so those are the only ones that must verify.
-        let mut all_external_git_paths_verified = true;
-        for path in &git_dirs {
-            if path_is_within_any(path, &writable_paths) {
-                continue;
-            }
-            let Some(normalized_path) = normalize_sandbox_git_path(path, fs).await else {
-                log::warn!(
-                    "Denying requested agent terminal Git metadata access because external Git metadata path `{}` could not be normalized",
-                    path.display()
-                );
-                all_external_git_paths_verified = false;
-                break;
-            };
-            if verified_git_paths.binary_search(&normalized_path).is_err() {
-                log::warn!(
-                    "Denying requested agent terminal Git metadata access because external Git metadata path `{}` (normalized to `{}`) was not verified from project repository metadata",
-                    path.display(),
-                    normalized_path.display()
-                );
-                all_external_git_paths_verified = false;
-                break;
-            }
-        }
-
-        // The current sandbox policy can make one Git directory set either all
-        // writable or all protected. Only grant Git access when every external
-        // candidate still verifies; otherwise keep protecting the original
-        // candidate set. The granted set is the verified paths only, so even
-        // when the grant proceeds, unverified `.git` metadata never becomes
-        // writable.
-        if all_external_git_paths_verified {
-            git_dirs = verified_git_paths;
-            allow_verified_git_access = true;
-        }
-    }
-
-    git_dirs.sort();
-    git_dirs.dedup();
-    writable_paths.sort();
-    writable_paths.dedup();
-
-    SandboxGitPaths {
-        writable_paths,
-        git_dirs,
-        allow_git_access: allow_verified_git_access,
-    }
-}
-
-async fn verified_sandbox_git_paths(
-    repository: SandboxGitRepositoryPaths,
-    fs: &dyn Fs,
-) -> Vec {
-    macro_rules! deny {
-        ($($arg:tt)*) => {{
-            log::debug!(
-                "Denying agent terminal Git metadata access for repository `{}` (dot_git: `{}`, repository_dir: `{}`, common_dir: `{}`): {}",
-                repository.work_directory_abs_path.display(),
-                repository.dot_git_abs_path.display(),
-                repository.repository_dir_abs_path.display(),
-                repository.common_dir_abs_path.display(),
-                format_args!($($arg)*)
-            );
-            return Vec::new();
-        }};
-    }
-
-    let Some(dot_git_abs_path) = normalize_sandbox_git_path(&repository.dot_git_abs_path, fs).await
-    else {
-        deny!(
-            "could not normalize .git path `{}`",
-            repository.dot_git_abs_path.display()
-        );
-    };
-    let Some(repository_dir_abs_path) =
-        normalize_sandbox_git_path(&repository.repository_dir_abs_path, fs).await
-    else {
-        deny!(
-            "could not normalize repository dir `{}`",
-            repository.repository_dir_abs_path.display()
-        );
-    };
-    let Some(common_dir_abs_path) =
-        normalize_sandbox_git_path(&repository.common_dir_abs_path, fs).await
-    else {
-        deny!(
-            "could not normalize common dir `{}`",
-            repository.common_dir_abs_path.display()
-        );
-    };
-
-    let dot_git_metadata = match fs.metadata(&repository.dot_git_abs_path).await {
-        Ok(Some(metadata)) => metadata,
-        Ok(None) => deny!(
-            ".git path `{}` does not exist",
-            repository.dot_git_abs_path.display()
-        ),
-        Err(error) => deny!(
-            "failed to read metadata for .git path `{}`: {error}",
-            repository.dot_git_abs_path.display()
-        ),
-    };
-    if dot_git_metadata.is_symlink {
-        deny!(
-            ".git path `{}` is a symlink",
-            repository.dot_git_abs_path.display()
-        );
-    }
-
-    if dot_git_metadata.is_dir {
-        if dot_git_abs_path != repository_dir_abs_path {
-            deny!(
-                "directory .git path `{}` normalized to `{}`, which does not match repository dir `{}` normalized to `{}`",
-                repository.dot_git_abs_path.display(),
-                dot_git_abs_path.display(),
-                repository.repository_dir_abs_path.display(),
-                repository_dir_abs_path.display()
-            );
-        }
-
-        if repository_dir_abs_path == common_dir_abs_path {
-            return vec![
-                dot_git_abs_path,
-                repository_dir_abs_path,
-                common_dir_abs_path,
-            ];
-        }
-
-        let Some(common_dir) = read_commondir_path(&repository_dir_abs_path, fs).await else {
-            deny!(
-                "repository dir `{}` did not contain a readable commondir pointing at expected common dir `{}`",
-                repository_dir_abs_path.display(),
-                common_dir_abs_path.display()
-            );
-        };
-        if common_dir == common_dir_abs_path {
-            return vec![
-                dot_git_abs_path,
-                repository_dir_abs_path,
-                common_dir_abs_path,
-            ];
-        }
-        deny!(
-            "repository dir `{}` commondir resolved to `{}`, expected `{}`",
-            repository_dir_abs_path.display(),
-            common_dir.display(),
-            common_dir_abs_path.display()
-        );
-    }
-
-    let Some(expected_dot_git_abs_path) =
-        normalize_sandbox_git_path(repository.work_directory_abs_path.join(".git"), fs).await
-    else {
-        deny!(
-            "could not normalize expected worktree .git path `{}`",
-            repository.work_directory_abs_path.join(".git").display()
-        );
-    };
-    if dot_git_abs_path != expected_dot_git_abs_path {
-        deny!(
-            ".git path `{}` normalized to `{}`, expected worktree .git path `{}`",
-            repository.dot_git_abs_path.display(),
-            dot_git_abs_path.display(),
-            expected_dot_git_abs_path.display()
-        );
-    }
-
-    let Some(stated_repository_dir) = read_gitfile_path(&repository.dot_git_abs_path, fs).await
-    else {
-        deny!(
-            "gitfile `{}` did not resolve to a readable, non-symlink repository dir",
-            repository.dot_git_abs_path.display()
-        );
-    };
-
-    if stated_repository_dir != repository_dir_abs_path {
-        deny!(
-            "gitfile `{}` resolved to repository dir `{}`, expected `{}`",
-            repository.dot_git_abs_path.display(),
-            stated_repository_dir.display(),
-            repository_dir_abs_path.display()
-        );
-    }
-
-    let Some(common_dir) = read_commondir_path(&stated_repository_dir, fs).await else {
-        if repository_dir_abs_path == common_dir_abs_path
-            && gitdir_belongs_to_submodule_worktree(
-                &repository_dir_abs_path,
-                &repository.work_directory_abs_path,
-                fs,
-            )
-            .await
-        {
-            return vec![dot_git_abs_path, repository_dir_abs_path];
-        }
-        deny!(
-            "repository dir `{}` has no verified commondir and did not verify as a submodule gitdir for worktree `{}`",
-            repository_dir_abs_path.display(),
-            repository.work_directory_abs_path.display()
-        );
-    };
-
-    if common_dir != common_dir_abs_path {
-        deny!(
-            "repository dir `{}` commondir resolved to `{}`, expected `{}`",
-            stated_repository_dir.display(),
-            common_dir.display(),
-            common_dir_abs_path.display()
-        );
-    }
-
-    if repository_dir_abs_path != common_dir_abs_path
-        && !linked_worktree_points_back(
-            &common_dir_abs_path,
-            &repository_dir_abs_path,
-            &dot_git_abs_path,
-            &repository.work_directory_abs_path,
-            fs,
-        )
-        .await
-    {
-        deny!(
-            "linked worktree repository dir `{}` did not point back to .git path `{}` and worktree `{}` under common dir `{}`",
-            repository_dir_abs_path.display(),
-            dot_git_abs_path.display(),
-            repository.work_directory_abs_path.display(),
-            common_dir_abs_path.display()
-        );
-    }
-
-    vec![
-        dot_git_abs_path,
-        repository_dir_abs_path,
-        common_dir_abs_path,
-    ]
-}
-
-async fn read_gitfile_path(dot_git_abs_path: &Path, fs: &dyn Fs) -> Option {
-    let contents = match fs.load(dot_git_abs_path).await {
-        Ok(contents) => contents,
-        Err(error) => {
-            log::debug!(
-                "Could not verify Git metadata path: failed to read gitfile `{}`: {error}",
-                dot_git_abs_path.display()
-            );
-            return None;
-        }
-    };
-    let Some(gitdir) = contents.strip_prefix("gitdir:") else {
-        log::debug!(
-            "Could not verify Git metadata path: gitfile `{}` does not start with `gitdir:`",
-            dot_git_abs_path.display()
-        );
-        return None;
-    };
-    let gitdir = Path::new(gitdir.trim());
-    let Some(dot_git_parent) = dot_git_abs_path.parent() else {
-        log::debug!(
-            "Could not verify Git metadata path: gitfile `{}` has no parent directory",
-            dot_git_abs_path.display()
-        );
-        return None;
-    };
-    let path = if gitdir.is_absolute() {
-        gitdir.to_path_buf()
-    } else {
-        dot_git_parent.join(gitdir)
-    };
-    match fs.metadata(&path).await {
-        Ok(Some(metadata)) if metadata.is_symlink => {
-            log::debug!(
-                "Could not verify Git metadata path: gitfile `{}` points to symlinked gitdir `{}`",
-                dot_git_abs_path.display(),
-                path.display()
-            );
-            return None;
-        }
-        Ok(_) => {}
-        Err(error) => {
-            log::debug!(
-                "Could not check whether gitfile `{}` points to a symlink at `{}`: {error}",
-                dot_git_abs_path.display(),
-                path.display()
-            );
-        }
-    }
-    let normalized_path = normalize_sandbox_git_path(&path, fs).await;
-    if normalized_path.is_none() {
-        log::debug!(
-            "Could not verify Git metadata path: gitfile `{}` points to gitdir `{}` that could not be normalized",
-            dot_git_abs_path.display(),
-            path.display()
-        );
-    }
-    normalized_path
-}
-
-async fn read_commondir_path(repository_dir_abs_path: &Path, fs: &dyn Fs) -> Option {
-    let commondir_abs_path = repository_dir_abs_path.join("commondir");
-    let commondir_contents = match fs.load(&commondir_abs_path).await {
-        Ok(contents) => contents,
-        Err(error) => {
-            log::debug!(
-                "Could not verify Git metadata path: failed to read commondir file `{}`: {error}",
-                commondir_abs_path.display()
-            );
-            return None;
-        }
-    };
-    let commondir_path = Path::new(commondir_contents.trim());
-    let path = if commondir_path.is_absolute() {
-        commondir_path.to_path_buf()
-    } else {
-        repository_dir_abs_path.join(commondir_path)
-    };
-    let normalized_path = normalize_sandbox_git_path(&path, fs).await;
-    if normalized_path.is_none() {
-        log::debug!(
-            "Could not verify Git metadata path: commondir file `{}` points to `{}` which could not be normalized",
-            commondir_abs_path.display(),
-            path.display()
-        );
-    }
-    normalized_path
-}
-
-async fn linked_worktree_points_back(
-    common_dir_abs_path: &Path,
-    repository_dir_abs_path: &Path,
-    dot_git_abs_path: &Path,
-    work_directory_abs_path: &Path,
-    fs: &dyn Fs,
-) -> bool {
-    let expected_repository_parent = common_dir_abs_path.join("worktrees");
-    if repository_dir_abs_path.parent() != Some(expected_repository_parent.as_path()) {
-        log::debug!(
-            "Could not verify linked worktree Git metadata: repository dir `{}` is not under expected worktrees dir `{}`",
-            repository_dir_abs_path.display(),
-            expected_repository_parent.display()
-        );
-        return false;
-    }
-
-    match fs.metadata(repository_dir_abs_path).await {
-        Ok(Some(metadata)) if metadata.is_dir && !metadata.is_symlink => {}
-        Ok(Some(metadata)) => {
-            log::debug!(
-                "Could not verify linked worktree Git metadata: repository dir `{}` has invalid metadata (is_dir: {}, is_symlink: {})",
-                repository_dir_abs_path.display(),
-                metadata.is_dir,
-                metadata.is_symlink
-            );
-            return false;
-        }
-        Ok(None) => {
-            log::debug!(
-                "Could not verify linked worktree Git metadata: repository dir `{}` does not exist",
-                repository_dir_abs_path.display()
-            );
-            return false;
-        }
-        Err(error) => {
-            log::debug!(
-                "Could not verify linked worktree Git metadata: failed to read metadata for repository dir `{}`: {error}",
-                repository_dir_abs_path.display()
-            );
-            return false;
-        }
-    }
-
-    let expected_dot_git_abs_path = work_directory_abs_path.join(".git");
-    let Some(expected_dot_git_abs_path) =
-        normalize_sandbox_git_path(&expected_dot_git_abs_path, fs).await
-    else {
-        log::debug!(
-            "Could not verify linked worktree Git metadata: expected .git path `{}` could not be normalized",
-            expected_dot_git_abs_path.display()
-        );
-        return false;
-    };
-    if dot_git_abs_path != expected_dot_git_abs_path {
-        log::debug!(
-            "Could not verify linked worktree Git metadata: .git path `{}` does not match expected worktree .git path `{}`",
-            dot_git_abs_path.display(),
-            expected_dot_git_abs_path.display()
-        );
-        return false;
-    }
-
-    let Some(listed_dot_git_path) = read_listed_worktree_gitdir(repository_dir_abs_path, fs).await
-    else {
-        return false;
-    };
-    if listed_dot_git_path != dot_git_abs_path {
-        log::debug!(
-            "Could not verify linked worktree Git metadata: repository dir `{}` lists .git path `{}`, expected `{}`",
-            repository_dir_abs_path.display(),
-            listed_dot_git_path.display(),
-            dot_git_abs_path.display()
-        );
-        return false;
-    }
-
-    true
-}
-
-async fn read_listed_worktree_gitdir(worktree_entry_dir: &Path, fs: &dyn Fs) -> Option {
-    let gitdir_abs_path = worktree_entry_dir.join("gitdir");
-    let gitdir_contents = match fs.load(&gitdir_abs_path).await {
-        Ok(contents) => contents,
-        Err(error) => {
-            log::debug!(
-                "Could not verify linked worktree Git metadata: failed to read worktree gitdir file `{}`: {error}",
-                gitdir_abs_path.display()
-            );
-            return None;
-        }
-    };
-    let gitdir_path = Path::new(gitdir_contents.trim());
-    let path = if gitdir_path.is_absolute() {
-        gitdir_path.to_path_buf()
-    } else {
-        worktree_entry_dir.join(gitdir_path)
-    };
-    let normalized_path = normalize_sandbox_git_path(&path, fs).await;
-    if normalized_path.is_none() {
-        log::debug!(
-            "Could not verify linked worktree Git metadata: worktree gitdir file `{}` points to `{}` which could not be normalized",
-            gitdir_abs_path.display(),
-            path.display()
-        );
-    }
-    normalized_path
-}
-
-async fn gitdir_belongs_to_submodule_worktree(
-    repository_dir_abs_path: &Path,
-    work_directory_abs_path: &Path,
-    fs: &dyn Fs,
-) -> bool {
-    let Some(work_directory_abs_path) =
-        normalize_sandbox_git_path(work_directory_abs_path, fs).await
-    else {
-        log::debug!(
-            "Could not verify submodule Git metadata: worktree path `{}` could not be normalized",
-            work_directory_abs_path.display()
-        );
-        return false;
-    };
-
-    let Some(core_worktree) = read_core_worktree(repository_dir_abs_path, fs).await else {
-        return false;
-    };
-    if core_worktree != work_directory_abs_path {
-        log::debug!(
-            "Could not verify submodule Git metadata: repository dir `{}` has core.worktree `{}`, expected `{}`",
-            repository_dir_abs_path.display(),
-            core_worktree.display(),
-            work_directory_abs_path.display()
-        );
-        return false;
-    }
-
-    true
-}
-
-async fn read_core_worktree(repository_dir_abs_path: &Path, fs: &dyn Fs) -> Option {
-    let config_abs_path = repository_dir_abs_path.join("config");
-    let config = match fs.load(&config_abs_path).await {
-        Ok(config) => config,
-        Err(error) => {
-            log::debug!(
-                "Could not verify submodule Git metadata: failed to read config `{}`: {error}",
-                config_abs_path.display()
-            );
-            return None;
-        }
-    };
-    let Some(core_worktree) = parse_core_worktree(&config) else {
-        log::debug!(
-            "Could not verify submodule Git metadata: config `{}` did not contain exactly one supported core.worktree value",
-            config_abs_path.display()
-        );
-        return None;
-    };
-    let path = Path::new(&core_worktree);
-    let path = if path.is_absolute() {
-        path.to_path_buf()
-    } else {
-        repository_dir_abs_path.join(path)
-    };
-    let normalized_path = normalize_sandbox_git_path(&path, fs).await;
-    if normalized_path.is_none() {
-        log::debug!(
-            "Could not verify submodule Git metadata: core.worktree value `{}` from config `{}` resolved to `{}` which could not be normalized",
-            core_worktree,
-            config_abs_path.display(),
-            path.display()
-        );
-    }
-    normalized_path
-}
-
-fn parse_core_worktree(config: &str) -> Option {
-    let mut in_core_section = false;
-    let mut core_worktree = None;
-
-    for raw_line in config.lines() {
-        let line = raw_line.trim();
-        if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
-            continue;
-        }
-        if line.ends_with('\\') {
-            return None;
-        }
-
-        if line.starts_with('[') {
-            if !line.ends_with(']') {
-                return None;
-            }
-            let section = line[1..line.len() - 1].trim();
-            if section.to_lowercase().starts_with("include") {
-                return None;
-            }
-            in_core_section = section.eq_ignore_ascii_case("core");
-            continue;
-        }
-
-        if !in_core_section {
-            continue;
-        }
-
-        let Some((key, value)) = line.split_once('=') else {
-            continue;
-        };
-        if !key.trim().eq_ignore_ascii_case("worktree") {
-            continue;
-        }
-        if core_worktree.is_some() {
-            return None;
-        }
-        core_worktree = Some(parse_git_config_path_value(value.trim())?);
-    }
-
-    core_worktree
-}
-
-fn parse_git_config_path_value(value: &str) -> Option {
-    if value.is_empty() {
-        return None;
-    }
-
-    if !value.starts_with('"') {
-        if value.contains('"') || value.starts_with('~') {
-            return None;
-        }
-        return Some(value.to_string());
-    }
-
-    let mut chars = value.chars();
-    chars.next()?;
-    let mut parsed = String::new();
-    let mut escaped = false;
-    let mut closed = false;
-    while let Some(character) = chars.next() {
-        if escaped {
-            match character {
-                '"' | '\\' => parsed.push(character),
-                _ => return None,
-            }
-            escaped = false;
-        } else if character == '\\' {
-            escaped = true;
-        } else if character == '"' {
-            closed = true;
-            break;
-        } else {
-            parsed.push(character);
-        }
-    }
-
-    if escaped || !closed {
-        return None;
-    }
-
-    let remaining = &value[value.len() - chars.as_str().len()..];
-    if !remaining.trim().is_empty() {
-        return None;
-    }
-
-    if parsed.is_empty() || parsed.starts_with('~') {
-        return None;
-    }
-
-    Some(parsed)
-}
-
-fn path_is_within_any(path: &Path, roots: &[PathBuf]) -> bool {
-    // `Path::starts_with` matches whole components, so `/projectX` is not
-    // treated as being within `/project`.
-    roots.iter().any(|root| path.starts_with(root))
-}
-
-async fn normalize_sandbox_git_path(path: impl AsRef, fs: &dyn Fs) -> Option {
-    if let Ok(path) = fs.canonicalize(path.as_ref()).await {
-        Some(path)
-    } else {
-        util::paths::normalize_lexically(path.as_ref()).ok()
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use fs::Fs;
-
-    #[gpui::test]
-    async fn test_sandbox_paths_protect_git_paths_until_git_access_is_allowed(
-        cx: &mut gpui::TestAppContext,
-    ) {
-        crate::tests::init_test(cx);
-
-        let fs = fs::FakeFs::new(cx.executor());
-        fs.insert_tree(
-            "/main_repo",
-            serde_json::json!({
-                ".git": {},
-                "file.txt": "content",
-            }),
-        )
-        .await;
-        fs.add_linked_worktree_for_repo(
-            Path::new("/main_repo/.git"),
-            false,
-            git::repository::Worktree {
-                path: PathBuf::from("/linked_worktree"),
-                ref_name: Some("refs/heads/feature".into()),
-                sha: "abc123".into(),
-                is_main: false,
-                is_bare: false,
-            },
-        )
-        .await;
-        fs.write(Path::new("/linked_worktree/file.txt"), b"content")
-            .await
-            .expect("linked worktree file should be written");
-
-        let project = project::Project::test(fs.clone(), [Path::new("/linked_worktree")], cx).await;
-        let candidates =
-            cx.update(|cx| SandboxGitPathCandidates::from_project(project.read(cx), cx));
-        let paths_without_git_access = sandbox_git_paths(candidates, fs.as_ref(), false).await;
-
-        assert!(
-            paths_without_git_access
-                .writable_paths
-                .contains(&PathBuf::from("/linked_worktree"))
-        );
-        assert!(
-            paths_without_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/linked_worktree/.git"))
-        );
-        assert!(
-            !paths_without_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/linked_worktree/.gitignore"))
-        );
-        assert!(
-            paths_without_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/main_repo/.git"))
-        );
-        assert!(
-            paths_without_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/main_repo/.git/worktrees/feature"))
-        );
-
-        let candidates =
-            cx.update(|cx| SandboxGitPathCandidates::from_project(project.read(cx), cx));
-        let paths_with_git_access = sandbox_git_paths(candidates, fs.as_ref(), true).await;
-
-        assert!(paths_with_git_access.allow_git_access);
-        assert!(
-            paths_with_git_access
-                .writable_paths
-                .contains(&PathBuf::from("/linked_worktree"))
-        );
-        assert!(
-            paths_with_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/linked_worktree/.git"))
-        );
-        assert!(
-            paths_with_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/main_repo/.git"))
-        );
-        assert!(
-            paths_with_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/main_repo/.git/worktrees/feature"))
-        );
-    }
-
-    #[gpui::test]
-    async fn test_sandbox_paths_grant_git_access_when_non_git_folder_is_present(
-        cx: &mut gpui::TestAppContext,
-    ) {
-        crate::tests::init_test(cx);
-
-        let fs = fs::FakeFs::new(cx.executor());
-        fs.insert_tree(
-            "/repo",
-            serde_json::json!({
-                ".git": {},
-                "file.txt": "content",
-            }),
-        )
-        .await;
-        // A plain folder opened alongside the repo. Its `/.git` placeholder
-        // never corresponds to a repository, so it must not block the grant for
-        // the real repo.
-        fs.insert_tree("/notes", serde_json::json!({ "todo.txt": "hi" }))
-            .await;
-
-        let project =
-            project::Project::test(fs.clone(), [Path::new("/repo"), Path::new("/notes")], cx).await;
-        let candidates =
-            cx.update(|cx| SandboxGitPathCandidates::from_project(project.read(cx), cx));
-        let paths_with_git_access = sandbox_git_paths(candidates, fs.as_ref(), true).await;
-
-        assert!(paths_with_git_access.allow_git_access);
-        assert!(
-            paths_with_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/repo/.git"))
-        );
-        assert!(
-            paths_with_git_access
-                .writable_paths
-                .contains(&PathBuf::from("/notes"))
-        );
-    }
-
-    #[gpui::test]
-    async fn test_sandbox_paths_allow_submodule_gitdir_without_commondir(
-        cx: &mut gpui::TestAppContext,
-    ) {
-        crate::tests::init_test(cx);
-
-        let fs = fs::FakeFs::new(cx.executor());
-        fs.insert_tree(
-            "/super",
-            serde_json::json!({
-                ".git": {
-                    "modules": {
-                        "sub": {
-                            "HEAD": "ref: refs/heads/main",
-                            "config": "[core]\n\trepositoryformatversion = 0\n\tworktree = ../../../sub\n"
-                        }
-                    }
-                },
-                "sub": {
-                    ".git": "gitdir: ../.git/modules/sub",
-                    "file.txt": "content"
-                }
-            }),
-        )
-        .await;
-
-        let project = project::Project::test(fs.clone(), [Path::new("/super/sub")], cx).await;
-        let candidates =
-            cx.update(|cx| SandboxGitPathCandidates::from_project(project.read(cx), cx));
-        let paths_with_git_access = sandbox_git_paths(candidates, fs.as_ref(), true).await;
-
-        assert!(paths_with_git_access.allow_git_access);
-        assert!(
-            paths_with_git_access
-                .writable_paths
-                .contains(&PathBuf::from("/super/sub"))
-        );
-        assert!(
-            paths_with_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/super/sub/.git"))
-        );
-        assert!(
-            paths_with_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/super/.git/modules/sub"))
-        );
-    }
-
-    #[gpui::test]
-    async fn test_sandbox_paths_do_not_grant_submodule_gitdir_without_back_reference(
-        cx: &mut gpui::TestAppContext,
-    ) {
-        crate::tests::init_test(cx);
-
-        let fs = fs::FakeFs::new(cx.executor());
-        fs.insert_tree(
-            "/super",
-            serde_json::json!({
-                ".git": {
-                    "modules": {
-                        "sub": {
-                            "HEAD": "ref: refs/heads/main",
-                            "config": "[core]\n\trepositoryformatversion = 0\n"
-                        }
-                    }
-                },
-                "sub": {
-                    ".git": "gitdir: ../.git/modules/sub",
-                    "file.txt": "content"
-                }
-            }),
-        )
-        .await;
-
-        let project = project::Project::test(fs.clone(), [Path::new("/super/sub")], cx).await;
-        let candidates =
-            cx.update(|cx| SandboxGitPathCandidates::from_project(project.read(cx), cx));
-        let paths_with_git_access = sandbox_git_paths(candidates, fs.as_ref(), true).await;
-
-        assert!(!paths_with_git_access.allow_git_access);
-        assert!(
-            paths_with_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/super/sub/.git"))
-        );
-        assert!(
-            paths_with_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/super/.git/modules/sub"))
-        );
-        assert!(
-            !paths_with_git_access
-                .writable_paths
-                .contains(&PathBuf::from("/super/.git/modules/sub"))
-        );
-    }
-
-    #[gpui::test]
-    async fn test_sandbox_paths_do_not_grant_submodule_gitfile_to_unrelated_gitdir(
-        cx: &mut gpui::TestAppContext,
-    ) {
-        crate::tests::init_test(cx);
-
-        let fs = fs::FakeFs::new(cx.executor());
-        fs.insert_tree(
-            "/project",
-            serde_json::json!({
-                "sub": {
-                    ".git": "gitdir: /other_repo/.git",
-                    "file.txt": "content"
-                }
-            }),
-        )
-        .await;
-        fs.insert_tree(
-            "/other_repo",
-            serde_json::json!({
-                ".git": {
-                    "HEAD": "ref: refs/heads/main",
-                    "config": "[core]\n\trepositoryformatversion = 0\n"
-                }
-            }),
-        )
-        .await;
-
-        let project = project::Project::test(fs.clone(), [Path::new("/project/sub")], cx).await;
-        let candidates =
-            cx.update(|cx| SandboxGitPathCandidates::from_project(project.read(cx), cx));
-        let paths_with_git_access = sandbox_git_paths(candidates, fs.as_ref(), true).await;
-
-        assert!(!paths_with_git_access.allow_git_access);
-        assert!(
-            paths_with_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/project/sub/.git"))
-        );
-        assert!(
-            paths_with_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/other_repo/.git"))
-        );
-        assert!(
-            !paths_with_git_access
-                .writable_paths
-                .contains(&PathBuf::from("/other_repo/.git"))
-        );
-    }
-
-    #[gpui::test]
-    async fn test_sandbox_paths_do_not_follow_gitfile_changed_after_scan(
-        cx: &mut gpui::TestAppContext,
-    ) {
-        crate::tests::init_test(cx);
-
-        let fs = fs::FakeFs::new(cx.executor());
-        fs.insert_tree(
-            "/main_repo",
-            serde_json::json!({
-                ".git": {},
-                "file.txt": "content",
-            }),
-        )
-        .await;
-        fs.add_linked_worktree_for_repo(
-            Path::new("/main_repo/.git"),
-            false,
-            git::repository::Worktree {
-                path: PathBuf::from("/linked_worktree"),
-                ref_name: Some("refs/heads/feature".into()),
-                sha: "abc123".into(),
-                is_main: false,
-                is_bare: false,
-            },
-        )
-        .await;
-        fs.write(Path::new("/linked_worktree/file.txt"), b"content")
-            .await
-            .expect("linked worktree file should be written");
-        fs.insert_tree(
-            "/other_repo",
-            serde_json::json!({
-                ".git": {
-                    "worktrees": {
-                        "other": {
-                            "HEAD": "ref: refs/heads/other",
-                            "commondir": "/other_repo/.git",
-                            "gitdir": "/other_worktree/.git"
-                        }
-                    }
-                }
-            }),
-        )
-        .await;
-
-        let project = project::Project::test(fs.clone(), [Path::new("/linked_worktree")], cx).await;
-        fs.write(
-            Path::new("/linked_worktree/.git"),
-            b"gitdir: /other_repo/.git/worktrees/other",
-        )
-        .await
-        .expect("mutated gitfile should be written");
-
-        let candidates =
-            cx.update(|cx| SandboxGitPathCandidates::from_project(project.read(cx), cx));
-        let paths_with_git_access = sandbox_git_paths(candidates, fs.as_ref(), true).await;
-
-        assert!(!paths_with_git_access.allow_git_access);
-        assert!(
-            paths_with_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/linked_worktree/.git"))
-        );
-        assert!(
-            paths_with_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/main_repo/.git"))
-        );
-        assert!(
-            paths_with_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/main_repo/.git/worktrees/feature"))
-        );
-        assert!(
-            !paths_with_git_access
-                .writable_paths
-                .contains(&PathBuf::from("/other_repo/.git"))
-        );
-        assert!(
-            !paths_with_git_access
-                .writable_paths
-                .contains(&PathBuf::from("/other_repo/.git/worktrees/other"))
-        );
-    }
-
-    #[gpui::test]
-    async fn test_sandbox_paths_do_not_grant_unverified_worktree_gitdir(
-        cx: &mut gpui::TestAppContext,
-    ) {
-        crate::tests::init_test(cx);
-
-        let fs = fs::FakeFs::new(cx.executor());
-        fs.insert_tree(
-            "/main_repo",
-            serde_json::json!({
-                ".git": {},
-                "file.txt": "content",
-            }),
-        )
-        .await;
-        fs.add_linked_worktree_for_repo(
-            Path::new("/main_repo/.git"),
-            false,
-            git::repository::Worktree {
-                path: PathBuf::from("/linked_worktree"),
-                ref_name: Some("refs/heads/feature".into()),
-                sha: "abc123".into(),
-                is_main: false,
-                is_bare: false,
-            },
-        )
-        .await;
-        fs.insert_tree(
-            "/other_repo",
-            serde_json::json!({
-                ".git": {
-                    "worktrees": {
-                        "other": {
-                            "HEAD": "ref: refs/heads/other",
-                            "commondir": "/other_repo/.git",
-                            "gitdir": "/other_worktree/.git"
-                        }
-                    }
-                }
-            }),
-        )
-        .await;
-        fs.write(
-            Path::new("/linked_worktree/.git"),
-            b"gitdir: /other_repo/.git/worktrees/other",
-        )
-        .await
-        .expect("malicious gitfile should be written");
-
-        let project = project::Project::test(fs.clone(), [Path::new("/linked_worktree")], cx).await;
-        let candidates =
-            cx.update(|cx| SandboxGitPathCandidates::from_project(project.read(cx), cx));
-        let paths_with_git_access = sandbox_git_paths(candidates, fs.as_ref(), true).await;
-
-        assert!(!paths_with_git_access.allow_git_access);
-        assert!(
-            paths_with_git_access
-                .writable_paths
-                .contains(&PathBuf::from("/linked_worktree"))
-        );
-        assert!(
-            !paths_with_git_access
-                .writable_paths
-                .contains(&PathBuf::from("/other_repo/.git"))
-        );
-        assert!(
-            !paths_with_git_access
-                .writable_paths
-                .contains(&PathBuf::from("/other_repo/.git/worktrees/other"))
-        );
-        assert!(
-            paths_with_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/linked_worktree/.git"))
-        );
-        assert!(
-            paths_with_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/other_repo/.git"))
-        );
-        assert!(
-            paths_with_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/other_repo/.git/worktrees/other"))
-        );
-    }
-
-    #[gpui::test]
-    async fn test_sandbox_paths_do_not_grant_symlinked_dot_git(cx: &mut gpui::TestAppContext) {
-        crate::tests::init_test(cx);
-
-        let fs = fs::FakeFs::new(cx.executor());
-        fs.insert_tree(
-            "/project",
-            serde_json::json!({
-                "file.txt": "content",
-            }),
-        )
-        .await;
-        fs.insert_tree(
-            "/other_repo",
-            serde_json::json!({
-                ".git": {}
-            }),
-        )
-        .await;
-        fs.insert_symlink(
-            Path::new("/project/.git"),
-            PathBuf::from("/other_repo/.git"),
-        )
-        .await;
-
-        let candidates = SandboxGitPathCandidates {
-            writable_paths: vec![PathBuf::from("/project")],
-            git_paths: vec![
-                PathBuf::from("/project/.git"),
-                PathBuf::from("/other_repo/.git"),
-            ],
-            repositories: vec![SandboxGitRepositoryPaths {
-                work_directory_abs_path: PathBuf::from("/project"),
-                dot_git_abs_path: PathBuf::from("/project/.git"),
-                repository_dir_abs_path: PathBuf::from("/other_repo/.git"),
-                common_dir_abs_path: PathBuf::from("/other_repo/.git"),
-            }],
-        };
-        let paths_with_git_access = sandbox_git_paths(candidates, fs.as_ref(), true).await;
-
-        assert!(!paths_with_git_access.allow_git_access);
-        assert!(
-            paths_with_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/project/.git"))
-        );
-        assert!(
-            paths_with_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/other_repo/.git"))
-        );
-        assert!(
-            !paths_with_git_access
-                .writable_paths
-                .contains(&PathBuf::from("/other_repo/.git"))
-        );
-    }
-
-    #[gpui::test]
-    async fn test_sandbox_paths_do_not_grant_symlinked_dot_git_file(cx: &mut gpui::TestAppContext) {
-        crate::tests::init_test(cx);
-
-        let fs = fs::FakeFs::new(cx.executor());
-        fs.insert_tree(
-            "/project",
-            serde_json::json!({
-                "file.txt": "content",
-            }),
-        )
-        .await;
-        fs.insert_tree(
-            "/other_repo",
-            serde_json::json!({
-                "gitfile": "gitdir: /other_repo/.git",
-                ".git": {
-                    "HEAD": "ref: refs/heads/main",
-                    "config": "[core]\n\trepositoryformatversion = 0\n\tworktree = /project\n"
-                }
-            }),
-        )
-        .await;
-        fs.insert_symlink(
-            Path::new("/project/.git"),
-            PathBuf::from("/other_repo/gitfile"),
-        )
-        .await;
-
-        let candidates = SandboxGitPathCandidates {
-            writable_paths: vec![PathBuf::from("/project")],
-            git_paths: vec![
-                PathBuf::from("/project/.git"),
-                PathBuf::from("/other_repo/.git"),
-            ],
-            repositories: vec![SandboxGitRepositoryPaths {
-                work_directory_abs_path: PathBuf::from("/project"),
-                dot_git_abs_path: PathBuf::from("/project/.git"),
-                repository_dir_abs_path: PathBuf::from("/other_repo/.git"),
-                common_dir_abs_path: PathBuf::from("/other_repo/.git"),
-            }],
-        };
-        let paths_with_git_access = sandbox_git_paths(candidates, fs.as_ref(), true).await;
-
-        assert!(!paths_with_git_access.allow_git_access);
-        assert!(
-            paths_with_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/project/.git"))
-        );
-        assert!(
-            paths_with_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/other_repo/.git"))
-        );
-        assert!(
-            !paths_with_git_access
-                .writable_paths
-                .contains(&PathBuf::from("/other_repo/.git"))
-        );
-    }
-
-    #[gpui::test]
-    async fn test_sandbox_paths_do_not_grant_gitfile_to_symlinked_gitdir(
-        cx: &mut gpui::TestAppContext,
-    ) {
-        crate::tests::init_test(cx);
-
-        let fs = fs::FakeFs::new(cx.executor());
-        fs.insert_tree(
-            "/project",
-            serde_json::json!({
-                ".git": "gitdir: /other_repo/gitdir_link",
-                "file.txt": "content",
-            }),
-        )
-        .await;
-        fs.insert_tree(
-            "/other_repo",
-            serde_json::json!({
-                ".git": {
-                    "HEAD": "ref: refs/heads/main",
-                    "config": "[core]\n\trepositoryformatversion = 0\n\tworktree = /project\n"
-                }
-            }),
-        )
-        .await;
-        fs.insert_symlink(
-            Path::new("/other_repo/gitdir_link"),
-            PathBuf::from("/other_repo/.git"),
-        )
-        .await;
-
-        let candidates = SandboxGitPathCandidates {
-            writable_paths: vec![PathBuf::from("/project")],
-            git_paths: vec![
-                PathBuf::from("/project/.git"),
-                PathBuf::from("/other_repo/gitdir_link"),
-                PathBuf::from("/other_repo/.git"),
-            ],
-            repositories: vec![SandboxGitRepositoryPaths {
-                work_directory_abs_path: PathBuf::from("/project"),
-                dot_git_abs_path: PathBuf::from("/project/.git"),
-                repository_dir_abs_path: PathBuf::from("/other_repo/gitdir_link"),
-                common_dir_abs_path: PathBuf::from("/other_repo/gitdir_link"),
-            }],
-        };
-        let paths_with_git_access = sandbox_git_paths(candidates, fs.as_ref(), true).await;
-
-        assert!(!paths_with_git_access.allow_git_access);
-        assert!(
-            paths_with_git_access
-                .git_dirs
-                .contains(&PathBuf::from("/other_repo/gitdir_link"))
-        );
-        assert!(
-            !paths_with_git_access
-                .writable_paths
-                .contains(&PathBuf::from("/other_repo/.git"))
-        );
-    }
-
-    #[test]
-    fn test_parse_core_worktree_accepts_simple_and_quoted_values() {
-        assert_eq!(
-            parse_core_worktree("[core]\n\tworktree = ../../../sub\n"),
-            Some("../../../sub".to_string())
-        );
-        assert_eq!(
-            parse_core_worktree("[core]\n\tworktree = \"../../../sub with spaces\"\n"),
-            Some("../../../sub with spaces".to_string())
-        );
-        assert_eq!(
-            parse_core_worktree("[core]\n\tworktree = \"C:/Users/Test/project/sub\"\n"),
-            Some("C:/Users/Test/project/sub".to_string())
-        );
-        assert_eq!(
-            parse_core_worktree("[core]\n\tworktree = \"C:\\\\Users\\\\Test\\\\project\\\\sub\"\n"),
-            Some("C:\\Users\\Test\\project\\sub".to_string())
-        );
-    }
-
-    #[test]
-    fn test_parse_core_worktree_rejects_ambiguous_or_unsupported_config() {
-        assert_eq!(parse_core_worktree("[core]\n\tworktree =\n"), None);
-        assert_eq!(
-            parse_core_worktree("[core]\n\tworktree = ../../../sub\n\tworktree = ../../../other\n"),
-            None
-        );
-        assert_eq!(parse_core_worktree("worktree = ../../../sub\n"), None);
-        assert_eq!(
-            parse_core_worktree(
-                "[include]\n\tpath = ../config\n[core]\n\tworktree = ../../../sub\n"
-            ),
-            None
-        );
-        assert_eq!(
-            parse_core_worktree("[core]\n\tworktree = \"../../../sub\" trailing\n"),
-            None
-        );
-        assert_eq!(
-            parse_core_worktree("[core]\n\tworktree = \"../../../sub\n"),
-            None
-        );
-        assert_eq!(
-            parse_core_worktree("[core]\n\tworktree = ../../../sub\\\n"),
-            None
-        );
-        assert_eq!(parse_core_worktree("[core]\n\tworktree = ~/sub\n"), None);
-    }
-}
diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs
index 037307e54eb5b5..5d884e8a6ce47e 100644
--- a/crates/agent_settings/src/agent_settings.rs
+++ b/crates/agent_settings/src/agent_settings.rs
@@ -422,8 +422,6 @@ pub struct SandboxPermissions {
     /// hostnames or leading-`*.` subdomain wildcards). Parsed/validated where
     /// consumed (`agent::sandboxing`).
     pub network_hosts: Vec,
-    /// Allow sandboxed commands to access protected Git metadata paths.
-    pub allow_git_access: bool,
     pub allow_fs_write_all: bool,
     /// Persistently run agent terminal commands outside the OS sandbox. This is
     /// the model-facing "off switch": when set, the sandboxed terminal tool is
@@ -820,7 +818,6 @@ fn compile_sandbox_permissions(
     SandboxPermissions {
         allow_all_hosts: content.allow_all_hosts.unwrap_or(false),
         network_hosts,
-        allow_git_access: content.allow_git_access.unwrap_or(false),
         allow_fs_write_all: content.allow_fs_write_all.unwrap_or(false),
         allow_unsandboxed: content.allow_unsandboxed.unwrap_or(false),
         write_paths,
@@ -1108,7 +1105,6 @@ mod tests {
         let json = json!({
             "allow_all_hosts": true,
             "network_hosts": ["github.com", "*.npmjs.org"],
-            "allow_git_access": true,
             "allow_unsandboxed": true,
             "write_paths": [
                 "/tmp/build/cache",
@@ -1125,7 +1121,6 @@ mod tests {
             permissions.network_hosts,
             vec!["github.com".to_string(), "*.npmjs.org".to_string()]
         );
-        assert!(permissions.allow_git_access);
         assert!(!permissions.allow_fs_write_all);
         assert!(permissions.allow_unsandboxed);
         assert_eq!(
diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs
index 8720ff145c02d7..fc0f5b96a580ce 100644
--- a/crates/agent_ui/src/conversation_view/thread_view.rs
+++ b/crates/agent_ui/src/conversation_view/thread_view.rs
@@ -21,7 +21,7 @@ use agent_skills::MAX_SKILL_DESCRIPTION_LEN;
 use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody};
 use editor::actions::OpenExcerpts;
 use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _};
-use sandbox::{GitSandboxPolicy, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy};
+use sandbox::{SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy};
 
 use crate::completion_provider::AvailableSkill;
 use crate::message_editor::SharedSessionCapabilities;
@@ -5788,7 +5788,6 @@ impl Render for TokenUsageTooltip {
 struct SandboxPolicyDisplay {
     fs: SandboxFsDisplay,
     network: SandboxNetPolicy,
-    git: SandboxGitDisplay,
 }
 
 /// The filesystem write-access portion of a [`SandboxPolicyDisplay`].
@@ -5810,22 +5809,14 @@ enum WritableEntryDisplay {
     IsolatedTmp,
 }
 
-/// The Git-access portion of a [`SandboxPolicyDisplay`]: whether `.git` writes
-/// are granted, and the (display-only) `.git` directories the policy governs.
-#[derive(Clone)]
-struct SandboxGitDisplay {
-    allowed: bool,
-    git_dirs: Vec,
-}
-
 impl SandboxPolicyDisplay {
     /// Display a policy verbatim (used for the per-thread overrides, which carry
     /// no implicit baseline grants). Takes the policy by reference and stringifies
     /// its locations immediately, so no fd is retained past this call.
     fn from_policy(policy: &SandboxPolicy) -> Self {
         let fs = match &policy.fs {
-            SandboxFsPolicy::Unrestricted => SandboxFsDisplay::Unrestricted,
-            SandboxFsPolicy::Restricted { writable_paths } => SandboxFsDisplay::Restricted(
+            SandboxFsPolicy::Unrestricted { .. } => SandboxFsDisplay::Unrestricted,
+            SandboxFsPolicy::Restricted { writable_paths, .. } => SandboxFsDisplay::Restricted(
                 writable_paths
                     .iter()
                     .map(|location| {
@@ -5837,21 +5828,6 @@ impl SandboxPolicyDisplay {
         SandboxPolicyDisplay {
             fs,
             network: policy.network.clone(),
-            git: SandboxGitDisplay::from_policy(&policy.git),
-        }
-    }
-}
-
-impl SandboxGitDisplay {
-    /// Stringify a Git policy's `.git` directories for display, retaining no fds.
-    fn from_policy(git: &GitSandboxPolicy) -> Self {
-        SandboxGitDisplay {
-            allowed: git.allows_writes(),
-            git_dirs: git
-                .git_dirs()
-                .iter()
-                .map(|location| location.untrusted_path_display().to_string())
-                .collect(),
         }
     }
 }
@@ -5868,8 +5844,8 @@ fn augment_settings_sandbox_policy(
     baseline: Vec,
 ) -> SandboxPolicyDisplay {
     let fs = match &policy.fs {
-        SandboxFsPolicy::Unrestricted => SandboxFsDisplay::Unrestricted,
-        SandboxFsPolicy::Restricted { writable_paths } => {
+        SandboxFsPolicy::Unrestricted { .. } => SandboxFsDisplay::Unrestricted,
+        SandboxFsPolicy::Restricted { writable_paths, .. } => {
             // Dedup by display string. We deliberately don't open the locations'
             // fds to dedup by inode here: this is a display-only tooltip and the
             // string is the location's identity for that purpose. The string can
@@ -5904,15 +5880,12 @@ fn augment_settings_sandbox_policy(
     SandboxPolicyDisplay {
         fs,
         network: policy.network.clone(),
-        git: SandboxGitDisplay::from_policy(&policy.git),
     }
 }
 
 fn sandbox_section(title: &str, policy: &SandboxPolicyDisplay, show_empty: bool) -> SandboxSection {
     let write_empty = fs_grants_nothing(&policy.fs);
     let network_empty = network_grants_nothing(&policy.network);
-    let git_empty = git_grants_nothing(&policy.git);
-
     let mut section = SandboxSection::new(title.to_string());
 
     if show_empty || !write_empty {
@@ -5925,40 +5898,13 @@ fn sandbox_section(title: &str, policy: &SandboxPolicyDisplay, show_empty: bool)
             .group(SandboxGroup::new("Network Access").rows(sandbox_network_rows(&policy.network)));
     }
 
-    if !git_empty {
-        section = section
-            .group(SandboxGroup::new("Git Metadata Access").rows(sandbox_git_rows(&policy.git)));
-    }
-
     section
 }
 
 /// Whether a policy grants nothing worth surfacing, used to decide whether to
 /// show the per-thread overrides section at all.
 fn sandbox_policy_grants_nothing(policy: &SandboxPolicyDisplay) -> bool {
-    fs_grants_nothing(&policy.fs)
-        && network_grants_nothing(&policy.network)
-        && git_grants_nothing(&policy.git)
-}
-
-/// Git access grants nothing to surface unless `.git` writes are allowed *and*
-/// at least one `.git` directory is known.
-fn git_grants_nothing(git: &SandboxGitDisplay) -> bool {
-    !git.allowed || git.git_dirs.is_empty()
-}
-
-/// Rows for the Git-access group: one row per writable `.git` directory (these
-/// may live outside the project for a linked worktree).
-fn sandbox_git_rows(git: &SandboxGitDisplay) -> Vec {
-    if !git.allowed {
-        return Vec::new();
-    }
-    git.git_dirs
-        .iter()
-        // The display string was captured up front; reconstruct a throwaway path
-        // here purely to render a label + icon (never used as an identity).
-        .map(|dir| SandboxRow::git(PathBuf::from(dir)))
-        .collect()
+    fs_grants_nothing(&policy.fs) && network_grants_nothing(&policy.network)
 }
 
 fn fs_grants_nothing(fs: &SandboxFsDisplay) -> bool {
@@ -5977,14 +5923,16 @@ fn network_grants_nothing(network: &SandboxNetPolicy) -> bool {
 /// row per granted path.
 fn sandbox_fs_rows(fs: &SandboxFsDisplay) -> Vec {
     match fs {
-        SandboxFsDisplay::Unrestricted => vec![SandboxRow::message("All paths (unrestricted)")],
+        SandboxFsDisplay::Unrestricted => vec![SandboxRow::message(
+            "All paths except protected Git metadata",
+        )],
         SandboxFsDisplay::Restricted(entries) if entries.is_empty() => {
             vec![SandboxRow::message("None")]
         }
         SandboxFsDisplay::Restricted(entries) => entries
             .iter()
             .map(|entry| match entry {
-                // The display string was captured up front; see `sandbox_git_rows`.
+                // The display string was captured up front.
                 WritableEntryDisplay::Path(path) => SandboxRow::path(PathBuf::from(path)),
                 #[cfg(target_os = "linux")]
                 WritableEntryDisplay::IsolatedTmp => {
@@ -8592,12 +8540,7 @@ impl ThreadView {
     ) -> AnyElement {
         let has_network = details.network_all_hosts || !details.network_hosts.is_empty();
         let has_write = details.allow_fs_write_all || !details.write_paths.is_empty();
-        if !has_network
-            && !has_write
-            && !details.allow_git_access
-            && !details.unsandboxed
-            && details.reason.is_empty()
-        {
+        if !has_network && !has_write && !details.unsandboxed && details.reason.is_empty() {
             return Empty.into_any_element();
         }
 
@@ -8701,7 +8644,7 @@ impl ThreadView {
 
         let write_section = has_write.then(|| {
             let summary = if details.allow_fs_write_all {
-                "unrestricted".to_string()
+                "unrestricted except Git metadata".to_string()
             } else {
                 format!(
                     "{} {}",
@@ -8809,23 +8752,6 @@ impl ThreadView {
                 )
         });
 
-        let git_access_section = details.allow_git_access.then(|| {
-            v_flex().px_2().py_1().gap_0p5().child(
-                h_flex()
-                    .gap_1()
-                    .child(
-                        Icon::new(IconName::GitBranch)
-                            .color(Color::Muted)
-                            .size(IconSize::Small),
-                    )
-                    .child(
-                        Label::new("Git metadata access")
-                            .size(LabelSize::Small)
-                            .color(Color::Muted),
-                    ),
-            )
-        });
-
         let reason_section = (!details.reason.is_empty()).then(|| {
             v_flex()
                 .px_2()
@@ -8845,7 +8771,6 @@ impl ThreadView {
         v_flex()
             .border_t_1()
             .border_color(self.tool_card_border_color(cx))
-            .children(git_access_section)
             .children(network_section)
             .children(write_section)
             .children(unsandboxed_section)
diff --git a/crates/agent_ui/src/ui/sandbox_status_tooltip.rs b/crates/agent_ui/src/ui/sandbox_status_tooltip.rs
index 089492bca9573b..059c9dd0837e9a 100644
--- a/crates/agent_ui/src/ui/sandbox_status_tooltip.rs
+++ b/crates/agent_ui/src/ui/sandbox_status_tooltip.rs
@@ -8,7 +8,6 @@ use ui::{Divider, prelude::*};
 pub enum SandboxRow {
     Message(SharedString),
     Path(PathBuf),
-    Git(PathBuf),
     Domain(SharedString),
 }
 
@@ -21,10 +20,6 @@ impl SandboxRow {
         Self::Path(path.into())
     }
 
-    pub fn git(path: impl Into) -> Self {
-        Self::Git(path.into())
-    }
-
     pub fn domain(domain: impl Into) -> Self {
         Self::Domain(domain.into())
     }
@@ -53,7 +48,6 @@ impl SandboxRow {
                     .unwrap_or_else(|| icon_basic(IconName::Folder));
                 (icon, path.display().to_string())
             }
-            SandboxRow::Git(path) => (icon_basic(IconName::GitBranch), path.display().to_string()),
             SandboxRow::Domain(domain) => (icon_basic(IconName::Public), domain.to_string()),
         };
 
@@ -234,17 +228,12 @@ impl Component for SandboxStatusTooltip {
             .group(
                 SandboxGroup::new("Write Access").row(SandboxRow::path("/Users/you/project/build")),
             )
-            .group(SandboxGroup::new("Network Access").row(SandboxRow::message("None")))
-            .group(
-                SandboxGroup::new("Git Metadata Access")
-                    .row(SandboxRow::git("/Users/you/project/.git")),
-            );
+            .group(SandboxGroup::new("Network Access").row(SandboxRow::message("None")));
 
         let unrestricted_section = SandboxSection::new("Defined in your settings:")
-            .group(
-                SandboxGroup::new("Write Access")
-                    .row(SandboxRow::message("All paths (unrestricted)")),
-            )
+            .group(SandboxGroup::new("Write Access").row(SandboxRow::message(
+                "All paths except protected Git metadata",
+            )))
             .group(
                 SandboxGroup::new("Network Access")
                     .row(SandboxRow::message("All domains (unrestricted)")),
diff --git a/crates/sandbox/README.md b/crates/sandbox/README.md
index d26bbedd2016c1..ab23ecad891a83 100644
--- a/crates/sandbox/README.md
+++ b/crates/sandbox/README.md
@@ -8,7 +8,7 @@ This crate allows creating a `Sandbox` according to some `SandboxPolicy`. A
 `SandboxPolicy` expresses:
 - what filesystem operations are allowed
 - which kinds of networking operations are allowed
-- whether git metadata is protected
+- which paths stay protected even under writable subtrees
 
 Once you have a `Sandbox`, you can use it to run commands that are constrained
 by that policy.
@@ -28,9 +28,9 @@ against attacks. An attacker with read/write access to the current directory can
 
 This can be mitigated by:
 - disabling any language servers with the capability to run untrusted code
-- not granting git access (since write access to `.git` can similarly be
-  escalated to unsandboxed code execution via `$EDITOR` and various other
-  methods)
+- keeping sensitive project metadata such as `.git` protected, since write
+  access to those paths can be escalated to unsandboxed code execution via hooks,
+  `$EDITOR`, and other mechanisms
 
 ## Implementation
 
@@ -44,6 +44,13 @@ The implementations are highly platform-specific:
 Note that WSL shells can be used on all Windows projects, regardless of whether
 the files are stored in the Linux filesystem or not.
 
+Though not defined in this crate, the default grants provided by the Zed agent is:
+- read-only access to all files
+- read/write access to current project directories
+  - read-only access to any Git metadata, including those in project directories
+- read/write access to an isolated tempdir, cleared between terminals
+  - on MacOS, this is set via the `$TMPDIR` variable and is not at `/tmp`
+
 ## Architecture
 
 Filesystem restrictions are different on all platforms. Network restrictions
@@ -73,26 +80,26 @@ However, this fails because of a nasty TOCTOU.
 Consider the following case:
 - an attacker has convinced the user to open `project`, which contains an evil
   `AGENTS.md`
-- They have also convinced the user to allow git access
+- They have also convinced the user to grant a writable path inside `project`
 - This means that the user will have given the following permissions to the sandbox:
   - read/write access to `project`
-  - read/write access to `project/.git`
+  - read/write access to `project/cache`
   - read/write access to an isolated `/tmp`
   - read-only access to `/`
 - The `AGENTS.md` instructs the LLM to do the following:
   - spawn two subagents
-  - the first subagent tries to swap `project/.git` with a symlink to
-    `/home/alice` [`renameat2(2)`][renameat2] with the `RENAME_EXCHANGE`
+  - the first subagent tries to swap `project/cache` with a symlink to
+    `/home/alice` using [`renameat2(2)`][renameat2] with the `RENAME_EXCHANGE`
     flag set
-  - the second subagent tries to run `echo 'export PATH="proj/obfuscated.../evil_eavesdropping_sudo/bin:$PATH"' >> proj/.git/.bashrc`
+  - the second subagent tries to run `echo 'export PATH="proj/obfuscated.../evil_eavesdropping_sudo/bin:$PATH"' >> proj/cache/.bashrc`
 - The user sends a prompt, we pick up the evil `AGENTS.md` instructions, and the
   agent does them
 - Zed checks whether paths are symlinks outside the allowable paths before
   passing them to bubblewrap, but there is a **time delay** between this check
   and when bubblewrap mounts them.
 - In this delay, the `renameat2` may succeed, which means that:
-  - At check time, `proj/.git` is a subdirectory of `proj`
-  - At bind time, `proj/.git` is a symlink to `/home/alice`
+  - At check time, `proj/cache` is a subdirectory of `proj`
+  - At bind time, `proj/cache` is a symlink to `/home/alice`
 - The attacker is now running code in a sandbox which has **read/write** access
   to `/home/alice`, and so the second command to inject the malicious
   credential-stealing sudo succeeds.
@@ -106,18 +113,18 @@ sequenceDiagram
     participant Zed as Zed path validation
     participant BW as bubblewrap
 
-    Note over Agent: Evil AGENTS.md picked up, git access granted
-    Note over Agent: Grants rw project, rw project/.git, rw /tmp, ro /
-    Agent->>S1: spawn swap project/.git for a symlink to /home/alice
-    Agent->>S2: spawn append PATH hijack to project/.git/.bashrc
-    Zed->>Zed: check project/.git is not an out-of-bounds symlink
+    Note over Agent: Evil AGENTS.md picked up, writable path granted
+    Note over Agent: Grants rw project, rw project/cache, rw /tmp, ro /
+    Agent->>S1: spawn swap project/cache for a symlink to /home/alice
+    Agent->>S2: spawn append PATH hijack to project/cache/.bashrc
+    Zed->>Zed: check project/cache is not an out-of-bounds symlink
     Note over Zed: at check time it is a real subdirectory, so OK
     Note over Zed,BW: time delay, time-of-check to time-of-use
     S1->>S1: renameat2 RENAME_EXCHANGE wins the race
-    Note over S1: project/.git is now a symlink to /home/alice
-    Zed->>BW: bind project/.git into the sandbox
-    BW->>BW: re-resolve project/.git, following symlink to /home/alice
-    S2->>BW: write project/.git/.bashrc
+    Note over S1: project/cache is now a symlink to /home/alice
+    Zed->>BW: bind project/cache into the sandbox
+    BW->>BW: re-resolve project/cache, following symlink to /home/alice
+    S2->>BW: write project/cache/.bashrc
     BW-->>S2: write lands in /home/alice/.bashrc
     Note over S2: escalation, project-scoped grant becomes arbitrary write
 ```
diff --git a/crates/sandbox/src/bwrap_test_helper.rs b/crates/sandbox/src/bwrap_test_helper.rs
index d0aaa29a83726f..f6aa0cb6f29377 100644
--- a/crates/sandbox/src/bwrap_test_helper.rs
+++ b/crates/sandbox/src/bwrap_test_helper.rs
@@ -35,8 +35,8 @@ mod imp {
 
     use anyhow::{Context as _, Result, bail};
     use sandbox::{
-        CommandAndArgs, GitSandboxPolicy, HostFilesystemLocation, Sandbox, SandboxError,
-        SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy,
+        CommandAndArgs, HostFilesystemLocation, Sandbox, SandboxError, SandboxFsPolicy,
+        SandboxNetPolicy, SandboxPolicy,
     };
     use serde::Deserialize;
 
@@ -112,14 +112,9 @@ mod imp {
         network_access: NetMode,
         #[serde(default)]
         allowed_domains: Vec,
-        /// `.git` directories to protect (contents read-only on Linux). Selects a
-        /// `Denied` Git policy. Mutually exclusive with `gitAllowed`.
+        /// Paths to protect from writes even if they fall under a writable path.
         #[serde(default)]
-        git_disabled: Vec,
-        /// `.git` directories to make writable. Selects an `Allowed` Git policy.
-        /// Mutually exclusive with `gitDisabled`.
-        #[serde(default)]
-        git_allowed: Vec,
+        protected_paths: Vec,
 
         // ---- operation (exactly one) ----
         /// Read this host path from inside the sandbox.
@@ -180,7 +175,9 @@ mod imp {
 
     fn policy_of(check: &Check) -> Result {
         let fs = match check.fs {
-            FsMode::Unrestricted => SandboxFsPolicy::Unrestricted,
+            FsMode::Unrestricted => SandboxFsPolicy::Unrestricted {
+                protected_paths: capture_protected_paths(&check.protected_paths),
+            },
             FsMode::Restricted => {
                 let mut writable_paths = Vec::new();
                 for path in &check.writable_paths {
@@ -194,7 +191,11 @@ mod imp {
                             .with_context(|| format!("failed to capture writable path {path}"))?,
                     );
                 }
-                SandboxFsPolicy::Restricted { writable_paths }
+                let protected_paths = capture_protected_paths(&check.protected_paths);
+                SandboxFsPolicy::Restricted {
+                    writable_paths,
+                    protected_paths,
+                }
             }
         };
         let network = match check.network_access {
@@ -204,29 +205,15 @@ mod imp {
                 allowed_domains: check.allowed_domains.clone(),
             },
         };
-        // `gitAllowed` and `gitDisabled` are mutually exclusive; `gitAllowed`
-        // wins if both are (mistakenly) set. With neither, the default protects
-        // an empty set of dirs, which is a no-op.
-        let git = if !check.git_allowed.is_empty() {
-            GitSandboxPolicy::Allowed {
-                git_dirs: capture_git_dirs(&check.git_allowed),
-            }
-        } else if !check.git_disabled.is_empty() {
-            GitSandboxPolicy::Denied {
-                git_dirs: capture_git_dirs(&check.git_disabled),
-            }
-        } else {
-            GitSandboxPolicy::default()
-        };
-        Ok(SandboxPolicy { fs, network, git })
+        Ok(SandboxPolicy { fs, network })
     }
 
-    /// Capture each already-existing `.git` directory, mirroring production's
-    /// fail-closed `filter_map(HostFilesystemLocation::new(..).ok())`: a `.git`
-    /// that does not yet exist can't be pinned and is simply skipped (the
-    /// documented Linux gap). Unlike writable paths, these are never created
-    /// here — whether one exists is exactly what several checks turn on.
-    fn capture_git_dirs(paths: &[String]) -> Vec {
+    /// Capture each already-existing protected path, mirroring production's
+    /// fail-closed `filter_map(HostFilesystemLocation::new(..).ok())`: a path
+    /// that does not yet exist can't be pinned and is simply skipped. Unlike
+    /// writable paths, these are never created here — whether one exists is
+    /// exactly what several checks turn on.
+    fn capture_protected_paths(paths: &[String]) -> Vec {
         paths
             .iter()
             .filter_map(|path| HostFilesystemLocation::new(path).ok())
@@ -237,14 +224,15 @@ mod imp {
         if let Some(name) = &check.name {
             return name.clone();
         }
-        let git = if !check.git_allowed.is_empty() {
-            format!(",git_allowed={:?}", check.git_allowed)
-        } else if !check.git_disabled.is_empty() {
-            format!(",git_disabled={:?}", check.git_disabled)
-        } else {
+        let protected = if check.protected_paths.is_empty() {
             String::new()
+        } else {
+            format!(",protected_paths={:?}", check.protected_paths)
         };
-        let policy = format!("fs={:?},net={:?}{git}", check.fs, check.network_access);
+        let policy = format!(
+            "fs={:?},net={:?}{protected}",
+            check.fs, check.network_access
+        );
         let op = if let Some(path) = &check.read {
             format!("read {path}")
         } else if let Some(path) = &check.write {
@@ -329,7 +317,7 @@ mod imp {
         if let Some(parent) = path.parent() {
             // Create the parent on the host so the only thing under test is the
             // sandbox's write permission, not a missing directory. This also
-            // makes a `.git` parent exist before the policy captures it.
+            // makes a protected parent exist before the policy captures it.
             std::fs::create_dir_all(parent)
                 .with_context(|| format!("failed to create parent of {}", path.display()))?;
         }
diff --git a/crates/sandbox/src/linux_bubblewrap.rs b/crates/sandbox/src/linux_bubblewrap.rs
index 18e445e02babeb..21a4602523bffe 100644
--- a/crates/sandbox/src/linux_bubblewrap.rs
+++ b/crates/sandbox/src/linux_bubblewrap.rs
@@ -221,7 +221,7 @@ fn probe_bwrap(bwrap: &Path, bwrap_args: &[String]) -> bool {
 /// the sandbox where the bridge connects to it.
 pub fn build_bwrap_args(
     writable_directories: &[&Path],
-    protected_git_dirs: &[&Path],
+    protected_paths: &[&Path],
     permissions: SandboxPermissions,
     cwd: Option<&Path>,
     proxy_socket_path: Option<&Path>,
@@ -231,7 +231,7 @@ pub fn build_bwrap_args(
         .map(|_| unique_proxy_socket_sandbox_path());
     build_bwrap_args_with_sandbox_paths(
         writable_directories,
-        protected_git_dirs,
+        protected_paths,
         permissions,
         cwd,
         proxy_socket_path,
@@ -247,7 +247,7 @@ pub fn build_bwrap_args(
 )]
 fn build_bwrap_args_with_sandbox_paths(
     writable_directories: &[&Path],
-    protected_git_dirs: &[&Path],
+    protected_paths: &[&Path],
     permissions: SandboxPermissions,
     cwd: Option<&Path>,
     proxy_socket_path: Option<&Path>,
@@ -290,24 +290,19 @@ fn build_bwrap_args_with_sandbox_paths(
             let path = directory.to_string_lossy().into_owned();
             push_bind(&mut args, "--bind", &path, &path);
         }
+    }
 
-        // Protect Git directories by re-binding them read-only *over* the rw
-        // worktree binds above (order matters: later binds win). Unlike
-        // Seatbelt, bwrap can't deny content reads while keeping metadata, so on
-        // Linux a protected `.git` is read-only — its contents stay readable but
-        // can't be written. A read-only re-bind needs no TOCTOU check: the whole
-        // root is already read-only, so re-exposing a path read-only grants
-        // nothing new even if its source was swapped. A not-yet-existing `.git`
-        // can't be bound, so it's skipped (a documented gap vs. macOS). When Git
-        // access is granted these dirs are in `writable_directories` instead and
-        // this list is empty.
-        for git_dir in protected_git_dirs {
-            if !git_dir.exists() {
-                continue;
-            }
-            let path = git_dir.to_string_lossy().into_owned();
-            push_bind(&mut args, "--ro-bind", &path, &path);
+    // Protect requested paths by re-binding them read-only *over* any broader rw
+    // bind (order matters: later binds win). Unlike Seatbelt, bwrap can't deny
+    // writes while allowing content reads with a policy rule, so a protected path
+    // is read-only — its contents stay readable but can't be written. A
+    // not-yet-existing path can't be bound, so it's skipped.
+    for protected_path in protected_paths {
+        if !protected_path.exists() {
+            continue;
         }
+        let path = protected_path.to_string_lossy().into_owned();
+        push_bind(&mut args, "--ro-bind", &path, &path);
     }
 
     for flag in [
@@ -595,7 +590,7 @@ pub fn wrap_invocation(
     bridge_program: &str,
     permissions: SandboxPermissions,
     writable_dirs: &[&Path],
-    protected_git_dirs: &[&Path],
+    protected_paths: &[&Path],
     cwd: Option<&Path>,
     program: &str,
     args: &[String],
@@ -637,7 +632,7 @@ pub fn wrap_invocation(
     };
     let mut bwrap_args = build_bwrap_args_with_sandbox_paths(
         writable_dirs,
-        protected_git_dirs,
+        protected_paths,
         permissions,
         cwd,
         proxy_socket_path,
@@ -1394,9 +1389,11 @@ mod tests {
             network: NetworkAccess::None,
             allow_fs_write: true,
         };
-        let args = build_bwrap_args(&[], &[], permissions, None, None);
+        let protected = Path::new("/tmp");
+        let args = build_bwrap_args(&[], &[protected], permissions, None, None);
         assert!(windows_contains(&args, &["--bind", "/", "/"]));
         assert!(!windows_contains(&args, &["--ro-bind", "/", "/"]));
+        assert!(windows_contains(&args, &["--ro-bind", "/tmp", "/tmp"]));
         assert!(!windows_contains(&args, &["--tmpfs", "/tmp"]));
     }
 
diff --git a/crates/sandbox/src/macos_seatbelt.rs b/crates/sandbox/src/macos_seatbelt.rs
index 91efe4467fe995..17a2db9928ed5c 100644
--- a/crates/sandbox/src/macos_seatbelt.rs
+++ b/crates/sandbox/src/macos_seatbelt.rs
@@ -88,9 +88,8 @@ impl SeatbeltConfigFile {
     /// directory of the command, since that is model-controlled and would
     /// let the model widen its own writable scope.
     ///
-    /// `protected_paths` lists paths whose file data reads and writes should
-    /// be blocked even if they fall under a readable or writable directory.
-    /// File metadata remains readable.
+    /// `protected_paths` lists paths whose writes should be blocked even if they
+    /// fall under a writable directory. Contents remain readable.
     ///
     /// `allowed_unix_socket_paths` lists Unix domain sockets the command may
     /// connect to for local IPC even when IP network access is otherwise
@@ -138,9 +137,8 @@ impl SeatbeltConfigFile {
 ///   the project's worktree paths here, not the working directory of the
 ///   command (the working directory is model-controlled, and using it as
 ///   the writable scope would let the model write outside the project).
-/// * `protected_paths` - Paths whose file data reads and writes should be
-///   denied even if they fall under a readable or writable directory. File
-///   metadata remains readable.
+/// * `protected_paths` - Paths whose writes should be denied even if they fall
+///   under a writable directory. Contents remain readable.
 /// * `allowed_unix_socket_paths` - Unix domain sockets the command may
 ///   connect to for local IPC even when IP network access is otherwise
 ///   disabled. This does not permit sending packets to other machines.
@@ -191,9 +189,8 @@ pub fn wrap_invocation(
 /// Writes to each entry in `writable_directories` (typically the project's
 /// worktree paths plus any per-command scratch directory the caller wants
 /// allowed) and the standard `/dev/*` write targets are also allowed by
-/// default. File data reads and writes to paths in `protected_paths` are
-/// denied even when they would otherwise be readable or writable; file
-/// metadata remains readable. Unix domain socket paths in
+/// default. Writes to paths in `protected_paths` are denied even when they
+/// would otherwise be writable; contents remain readable. Unix domain socket paths in
 /// `allowed_unix_socket_paths` are reachable for local IPC even when IP
 /// network access is otherwise blocked. This does not permit sending packets
 /// to other machines.
@@ -211,7 +208,7 @@ fn generate_seatbelt_config(
 ) -> Result {
     // These paths are already the canonical identities captured once, at
     // validation time, inside each `HostFilesystemLocation` (resolving symlinks
-    // and, for a not-yet-created `.git`, its existing parent). We deliberately do
+    // and, for a not-yet-created leaf, its existing parent). We deliberately do
     // NOT re-canonicalize here: re-resolving a path at profile-generation time
     // is the time-of-check-to-time-of-use hole this design closes. Use them
     // verbatim as Seatbelt rule literals.
@@ -311,12 +308,11 @@ fn generate_seatbelt_config(
     for protected_path in &canonical_protected_paths {
         let escaped_path = escape_sandbox_path(protected_path)?;
         // `subpath` already matches the path itself plus everything beneath it,
-        // so it covers both a `.git` directory and a linked worktree's `.git`
-        // gitlink file without a redundant `literal` rule.
+        // so no redundant `literal` rule is needed.
         config.push_str(&format!(
             r#"
-; Block Git metadata content access unless Git access is approved
-(deny file-read-data file-write*
+; Block protected path writes
+(deny file-write*
     (subpath "{escaped_path}"))
 "#
         ));
@@ -540,7 +536,7 @@ mod tests {
     }
 
     #[test]
-    fn test_generate_seatbelt_config_denies_protected_path_data_and_writes() {
+    fn test_generate_seatbelt_config_denies_protected_path_writes() {
         let dir = PathBuf::from("/Users/test/projects/myproject");
         let protected = dir.join(".gitignore");
         let config = generate_seatbelt_config(
@@ -551,14 +547,15 @@ mod tests {
         )
         .unwrap();
 
-        assert!(config.contains("(deny file-read-data file-write*"));
+        assert!(config.contains("(deny file-write*"));
+        assert!(!config.contains("file-read-data"));
         assert!(config.contains("(subpath \"/Users/test/projects/myproject/.gitignore\")"));
         // `subpath` already covers the path itself, so no redundant `literal`.
         assert!(!config.contains("(literal \"/Users/test/projects/myproject/.gitignore\")"));
     }
 
     #[test]
-    fn test_sandbox_blocks_protected_path_contents_but_allows_metadata() {
+    fn test_sandbox_allows_protected_path_reads_but_blocks_writes() {
         use std::process::Command;
 
         let temp_dir = tempfile::tempdir().unwrap();
@@ -574,7 +571,7 @@ mod tests {
             &[
                 "-c".to_string(),
                 format!(
-                    "test -e '{}' && ! cat '{}' >/dev/null 2>&1 && ! sh -c 'echo changed > {}'",
+                    "test -e '{}' && cat '{}' >/dev/null 2>&1 && ! sh -c 'echo changed > {}'",
                     protected_file.display(),
                     protected_file.display(),
                     protected_file.display(),
@@ -594,7 +591,7 @@ mod tests {
 
         assert!(
             output.status.success(),
-            "sandbox should allow metadata but deny protected data reads and writes: stderr={} stdout={}",
+            "sandbox should allow protected reads but deny protected writes: stderr={} stdout={}",
             String::from_utf8_lossy(&output.stderr),
             String::from_utf8_lossy(&output.stdout),
         );
@@ -620,7 +617,7 @@ mod tests {
             &[
                 "-c".to_string(),
                 format!(
-                    "! cat '{}' >/dev/null 2>&1 && ! sh -c 'echo changed > {}'",
+                    "cat '{}' >/dev/null 2>&1 && ! sh -c 'echo changed > {}'",
                     protected_file.display(),
                     protected_file.display(),
                 ),
@@ -642,7 +639,7 @@ mod tests {
 
         assert!(
             output.status.success(),
-            "protected paths should stay blocked even with unrestricted writes: stderr={} stdout={}",
+            "protected paths should stay readable but not writable even with unrestricted writes: stderr={} stdout={}",
             String::from_utf8_lossy(&output.stderr),
             String::from_utf8_lossy(&output.stdout),
         );
diff --git a/crates/sandbox/src/sandbox.rs b/crates/sandbox/src/sandbox.rs
index 4873c46ed514b9..067a4c50c1a5fd 100644
--- a/crates/sandbox/src/sandbox.rs
+++ b/crates/sandbox/src/sandbox.rs
@@ -16,6 +16,8 @@ use std::{
 use http_proxy::ProxyHandle;
 #[cfg(not(target_os = "windows"))]
 use http_proxy::{Allowlist, HostPattern, ProxyConfig, ProxyEvent, UpstreamProxy};
+#[cfg(target_os = "linux")]
+use std::os::fd::AsRawFd as _;
 
 #[cfg(target_os = "linux")]
 mod linux_bubblewrap;
@@ -30,7 +32,7 @@ mod windows_wsl;
 pub(crate) const WSL_SANDBOX_UNAVAILABLE_PREFIX: &str = "Windows sandboxing via WSL is unavailable";
 
 /// An opaque handle to a location on the **host** filesystem the sandbox may
-/// grant access to (a writable subtree, a `.git` directory, …).
+/// grant access to or protect (for example, a writable or protected subtree).
 ///
 /// The entire purpose of this type is to capture the *security-relevant identity*
 /// of a host location once, up front, in a form the enforcement layer can use
@@ -83,7 +85,7 @@ impl HostFilesystemLocation {
     /// On macOS this canonicalizes the path; on Linux it opens an `O_PATH`
     /// descriptor to it; on Windows it records nothing. The caller is
     /// responsible for having already *validated* `path` (e.g. confirmed it is
-    /// inside the project, or that a `.git` is not a symlink) — capturing it here
+    /// inside the project, or safe to treat as a protected path) — capturing it here
     /// pins that decision against later tampering. To be race-free, capture
     /// should happen as part of, or immediately after, that validation, and the
     /// resulting value should be passed around unchanged from then on (never
@@ -95,8 +97,8 @@ impl HostFilesystemLocation {
         #[cfg(target_os = "macos")]
         {
             // `canonicalize_allowing_missing_leaf` resolves through the existing
-            // parent so a not-yet-created leaf (e.g. a `.git` before `git init`)
-            // still yields the real path Seatbelt will match against.
+            // parent so a not-yet-created leaf still yields the real path
+            // Seatbelt will match against.
             let canonical_path = canonicalize_allowing_missing_leaf(path);
             Ok(Self {
                 canonical_path,
@@ -196,7 +198,10 @@ impl PartialEq for HostFilesystemLocation {
     fn eq(&self, other: &Self) -> bool {
         #[cfg(target_os = "linux")]
         {
-            match (linux_fd_identity(&self.fd), linux_fd_identity(&other.fd)) {
+            match (
+                linux_fd_identity(self.fd.as_raw_fd()),
+                linux_fd_identity(other.fd.as_raw_fd()),
+            ) {
                 (Some(a), Some(b)) => a == b,
                 // An `fstat` on an `O_PATH` fd we own should never fail; if it
                 // somehow does we can't prove identity, so report "not equal"
@@ -230,9 +235,8 @@ impl Eq for HostFilesystemLocation {}
 /// The `(device, inode)` pair behind an `O_PATH` descriptor, used to decide
 /// whether two [`HostFilesystemLocation`]s refer to the same filesystem object.
 #[cfg(target_os = "linux")]
-fn linux_fd_identity(fd: &std::os::fd::OwnedFd) -> Option<(u64, u64)> {
-    use std::os::fd::AsRawFd as _;
-    let stat = nix::sys::stat::fstat(fd.as_raw_fd()).ok()?;
+fn linux_fd_identity(fd: std::os::fd::RawFd) -> Option<(u64, u64)> {
+    let stat = nix::sys::stat::fstat(fd).ok()?;
     Some((stat.st_dev as u64, stat.st_ino as u64))
 }
 
@@ -279,20 +283,23 @@ impl From for SandboxFilesystemLocation {
 pub struct SandboxPolicy {
     pub fs: SandboxFsPolicy,
     pub network: SandboxNetPolicy,
-    pub git: GitSandboxPolicy,
 }
 
 /// Filesystem policy for a sandboxed command.
 #[derive(Clone, Debug, Eq, PartialEq)]
 pub enum SandboxFsPolicy {
-    /// Allow unrestricted filesystem writes.
-    Unrestricted,
+    /// Allow unrestricted filesystem writes except for protected paths, which
+    /// remain readable but not writable.
+    Unrestricted {
+        protected_paths: Vec,
+    },
     /// Reads are allowed everywhere; writes are confined to these locations
     /// (and the standard ephemeral locations the platform provides). Each is a
     /// [`HostFilesystemLocation`] captured at validation time, never a bare path
     /// the enforcement layer would re-resolve.
     Restricted {
         writable_paths: Vec,
+        protected_paths: Vec,
     },
 }
 
@@ -308,94 +315,148 @@ pub enum SandboxNetPolicy {
     Restricted { allowed_domains: Vec },
 }
 
-/// Policy for the project's Git directories (`.git`). The agent computes the
-/// directory list because locating it requires Git knowledge the sandbox layer
-/// can't derive itself — a linked worktree's `.git` is a gitlink pointing at a
-/// common dir elsewhere, and discovered repositories can live outside the
-/// project. Both variants carry the same dirs; the variant selects how they're
-/// treated.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum GitSandboxPolicy {
-    /// `.git` contents are protected: read-only on Linux and Windows/WSL;
-    /// content reads and writes denied on macOS (metadata stays visible either
-    /// way).
-    Denied {
-        git_dirs: Vec,
-    },
-    /// `.git` contents are writable (these dirs are made writable).
-    Allowed {
-        git_dirs: Vec,
-    },
-}
+/// Host paths that should remain readable but not writable, even when they fall
+/// under a writable subtree.
+///
+/// The caller computes this list because the sandbox layer does not know which
+/// application-specific paths need stronger protection.
+pub type ProtectedPaths = Vec;
 
-impl Default for GitSandboxPolicy {
-    /// Git directories protected, none known — the safe default.
-    fn default() -> Self {
-        GitSandboxPolicy::Denied {
-            git_dirs: Vec::new(),
+impl SandboxPolicy {
+    /// Combine two policy layers. Filesystem/network grants are unioned into the
+    /// least-restrictive policy that satisfies both layers, while protected paths
+    /// within restricted filesystem policies are unioned so every layer's
+    /// protected subtrees remain protected.
+    pub fn merge(self, other: SandboxPolicy) -> SandboxPolicy {
+        SandboxPolicy {
+            fs: self.fs.merge(other.fs),
+            network: self.network.merge(other.network),
         }
     }
-}
 
-impl GitSandboxPolicy {
-    /// The `.git` directories this policy governs, regardless of variant.
-    pub fn git_dirs(&self) -> &[HostFilesystemLocation] {
-        match self {
-            GitSandboxPolicy::Denied { git_dirs } | GitSandboxPolicy::Allowed { git_dirs } => {
-                git_dirs
+    /// Replace the protected paths in the filesystem policy, keeping writable
+    /// paths and network policy unchanged.
+    pub fn with_protected_paths(mut self, protected_paths: Vec) -> Self {
+        match &mut self.fs {
+            SandboxFsPolicy::Unrestricted {
+                protected_paths: existing,
             }
+            | SandboxFsPolicy::Restricted {
+                protected_paths: existing,
+                ..
+            } => *existing = protected_paths,
         }
+        self
     }
+}
 
-    /// Whether `.git` contents are writable.
-    pub fn allows_writes(&self) -> bool {
-        matches!(self, GitSandboxPolicy::Allowed { .. })
+fn merge_locations(
+    mut locations: Vec,
+    other: Vec,
+) -> Vec {
+    for location in other {
+        if !locations.contains(&location) {
+            locations.push(location);
+        }
     }
+    locations
+}
 
-    /// Combine two layers: `Allowed` (more permissive) wins, and the governed
-    /// `.git` directories union.
-    pub fn merge(self, other: GitSandboxPolicy) -> GitSandboxPolicy {
-        let allowed = self.allows_writes() || other.allows_writes();
-        let (GitSandboxPolicy::Denied { mut git_dirs }
-        | GitSandboxPolicy::Allowed { mut git_dirs }) = self;
-        let (GitSandboxPolicy::Denied {
-            git_dirs: other_dirs,
-        }
-        | GitSandboxPolicy::Allowed {
-            git_dirs: other_dirs,
-        }) = other;
-        for path in other_dirs {
-            if !git_dirs.contains(&path) {
-                git_dirs.push(path);
+fn validate_writable_paths_do_not_overlap_protected_paths(
+    writable_paths: &[HostFilesystemLocation],
+    protected_paths: &[HostFilesystemLocation],
+) -> Result<(), SandboxError> {
+    for writable_path in writable_paths {
+        for protected_path in protected_paths {
+            if writable_path_overlaps_protected_path(writable_path, protected_path) {
+                return Err(SandboxError::InvalidRequest(format!(
+                    "writable sandbox path `{}` overlaps protected path `{}`",
+                    writable_path.untrusted_path_display(),
+                    protected_path.untrusted_path_display()
+                )));
             }
         }
-        if allowed {
-            GitSandboxPolicy::Allowed { git_dirs }
-        } else {
-            GitSandboxPolicy::Denied { git_dirs }
-        }
     }
+    Ok(())
 }
 
-impl SandboxPolicy {
-    /// Combine two policies into the least-restrictive policy that satisfies
-    /// both (a set union per dimension). Used to layer the user's persistent
-    /// settings, this thread's grants, and a command's request into the single
-    /// policy that is actually enforced.
-    pub fn merge(self, other: SandboxPolicy) -> SandboxPolicy {
-        SandboxPolicy {
-            fs: self.fs.merge(other.fs),
-            network: self.network.merge(other.network),
-            git: self.git.merge(other.git),
-        }
+#[cfg(target_os = "linux")]
+fn writable_path_overlaps_protected_path(
+    writable_path: &HostFilesystemLocation,
+    protected_path: &HostFilesystemLocation,
+) -> bool {
+    linux_location_is_equal_or_descendant(writable_path, protected_path)
+}
+
+#[cfg(target_os = "macos")]
+fn writable_path_overlaps_protected_path(
+    writable_path: &HostFilesystemLocation,
+    protected_path: &HostFilesystemLocation,
+) -> bool {
+    writable_path
+        .macos_canonical_path()
+        .starts_with(protected_path.macos_canonical_path())
+}
+
+#[cfg(not(any(target_os = "linux", target_os = "macos")))]
+fn writable_path_overlaps_protected_path(
+    writable_path: &HostFilesystemLocation,
+    protected_path: &HostFilesystemLocation,
+) -> bool {
+    writable_path
+        .untrusted_path_for_display
+        .starts_with(&protected_path.untrusted_path_for_display)
+}
+
+#[cfg(target_os = "linux")]
+fn linux_location_is_equal_or_descendant(
+    location: &HostFilesystemLocation,
+    ancestor: &HostFilesystemLocation,
+) -> bool {
+    use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd};
+
+    if location == ancestor {
+        return true;
     }
 
-    /// Replace the Git policy, keeping the fs/network policy. The UI builds the
-    /// fs/network halves from settings/grants (which don't know the project's
-    /// `.git` locations) and then attaches the agent-computed Git policy here.
-    pub fn with_git(mut self, git: GitSandboxPolicy) -> Self {
-        self.git = git;
-        self
+    let Ok(mut current) = location.linux_dup_fd() else {
+        log::warn!("failed to duplicate sandbox location fd while checking protected path overlap");
+        return false;
+    };
+
+    loop {
+        let parent = unsafe {
+            let fd = libc::openat(
+                current.as_raw_fd(),
+                c"..".as_ptr(),
+                libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC,
+            );
+            if fd < 0 {
+                return false;
+            }
+            OwnedFd::from_raw_fd(fd)
+        };
+
+        let Some(parent_identity) = linux_fd_identity(parent.as_raw_fd()) else {
+            log::warn!("failed to fstat parent fd while checking protected path overlap");
+            return false;
+        };
+        let Some(current_identity) = linux_fd_identity(current.as_raw_fd()) else {
+            log::warn!("failed to fstat current fd while checking protected path overlap");
+            return false;
+        };
+        let Some(ancestor_identity) = linux_fd_identity(ancestor.linux_fd().as_raw_fd()) else {
+            log::warn!("failed to fstat protected fd while checking protected path overlap");
+            return false;
+        };
+
+        if parent_identity == ancestor_identity {
+            return true;
+        }
+        if parent_identity == current_identity {
+            return false;
+        }
+        current = parent;
     }
 }
 
@@ -403,22 +464,49 @@ impl SandboxFsPolicy {
     /// Unrestricted access dominates; otherwise the writable subtrees union.
     pub fn merge(self, other: SandboxFsPolicy) -> SandboxFsPolicy {
         match (self, other) {
-            (SandboxFsPolicy::Unrestricted, _) | (_, SandboxFsPolicy::Unrestricted) => {
-                SandboxFsPolicy::Unrestricted
-            }
             (
+                SandboxFsPolicy::Unrestricted {
+                    protected_paths: protected_a,
+                },
+                SandboxFsPolicy::Unrestricted {
+                    protected_paths: protected_b,
+                },
+            ) => SandboxFsPolicy::Unrestricted {
+                protected_paths: merge_locations(protected_a, protected_b),
+            },
+            (
+                SandboxFsPolicy::Unrestricted {
+                    protected_paths: protected_a,
+                },
                 SandboxFsPolicy::Restricted {
-                    writable_paths: mut a,
+                    protected_paths: protected_b,
+                    ..
                 },
-                SandboxFsPolicy::Restricted { writable_paths: b },
-            ) => {
-                for path in b {
-                    if !a.contains(&path) {
-                        a.push(path);
-                    }
-                }
-                SandboxFsPolicy::Restricted { writable_paths: a }
-            }
+            )
+            | (
+                SandboxFsPolicy::Restricted {
+                    protected_paths: protected_a,
+                    ..
+                },
+                SandboxFsPolicy::Unrestricted {
+                    protected_paths: protected_b,
+                },
+            ) => SandboxFsPolicy::Unrestricted {
+                protected_paths: merge_locations(protected_a, protected_b),
+            },
+            (
+                SandboxFsPolicy::Restricted {
+                    writable_paths: writable_a,
+                    protected_paths: protected_a,
+                },
+                SandboxFsPolicy::Restricted {
+                    writable_paths: writable_b,
+                    protected_paths: protected_b,
+                },
+            ) => SandboxFsPolicy::Restricted {
+                writable_paths: merge_locations(writable_a, writable_b),
+                protected_paths: merge_locations(protected_a, protected_b),
+            },
         }
     }
 }
@@ -537,6 +625,7 @@ impl std::error::Error for SandboxError {}
 struct FsSetup {
     allow_fs_write: bool,
     writable_paths: Vec,
+    protected_paths: Vec,
 }
 
 /// Resolved network plan derived from [`SandboxNetPolicy`]. For the restricted
@@ -563,8 +652,6 @@ enum NetSetup {
 pub struct Sandbox {
     fs: FsSetup,
     network: NetSetup,
-    /// The project's `.git` directories and whether they're writable.
-    git: GitSandboxPolicy,
     /// In-process network proxy for the restricted-network case, spawned on the
     /// first `wrap`. Dropped on a background thread (the join blocks).
     proxy: Option,
@@ -598,14 +685,25 @@ impl Sandbox {
     /// availability is checked separately via [`Sandbox::can_create`].
     pub fn new(policy: SandboxPolicy) -> Result {
         let fs = match policy.fs {
-            SandboxFsPolicy::Unrestricted => FsSetup {
+            SandboxFsPolicy::Unrestricted { protected_paths } => FsSetup {
                 allow_fs_write: true,
                 writable_paths: Vec::new(),
+                protected_paths,
             },
-            SandboxFsPolicy::Restricted { writable_paths } => FsSetup {
-                allow_fs_write: false,
+            SandboxFsPolicy::Restricted {
                 writable_paths,
-            },
+                protected_paths,
+            } => {
+                validate_writable_paths_do_not_overlap_protected_paths(
+                    &writable_paths,
+                    &protected_paths,
+                )?;
+                FsSetup {
+                    allow_fs_write: false,
+                    writable_paths,
+                    protected_paths,
+                }
+            }
         };
 
         let network = match policy.network {
@@ -619,7 +717,6 @@ impl Sandbox {
         Ok(Self {
             fs,
             network,
-            git: policy.git,
             proxy: None,
             #[cfg(target_os = "linux")]
             validation_fd_sender: None,
@@ -657,7 +754,7 @@ impl Sandbox {
         {
             let permissions = linux_bubblewrap::SandboxPermissions {
                 network: linux_probe_network(&policy.network),
-                allow_fs_write: matches!(policy.fs, SandboxFsPolicy::Unrestricted),
+                allow_fs_write: matches!(policy.fs, SandboxFsPolicy::Unrestricted { .. }),
             };
             linux_bubblewrap::check_can_create_sandbox(permissions).map_err(map_linux_status)
         }
@@ -773,19 +870,11 @@ impl Sandbox {
         Ok(Some((port, proxy.socket_path().map(PathBuf::from))))
     }
 
-    /// Split the policy's `.git` directories into (writable, protected) sets for
-    /// the enforcement layers. When the whole filesystem is writable the split
-    /// is empty (Git protection is moot); otherwise `Allowed` git dirs are
-    /// writable and `Denied` git dirs are protected.
+    /// Return the protected paths for the enforcement layer. Even with broad
+    /// filesystem writes, protected paths remain readable but not writable.
     #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
-    fn git_path_split(&self) -> (Vec, Vec) {
-        if self.fs.allow_fs_write {
-            return (Vec::new(), Vec::new());
-        }
-        match &self.git {
-            GitSandboxPolicy::Allowed { git_dirs } => (git_dirs.clone(), Vec::new()),
-            GitSandboxPolicy::Denied { git_dirs } => (Vec::new(), git_dirs.clone()),
-        }
+    fn protected_paths(&self) -> Vec {
+        self.fs.protected_paths.clone()
     }
 
     #[cfg(target_os = "linux")]
@@ -806,7 +895,7 @@ impl Sandbox {
             network,
             allow_fs_write: self.fs.allow_fs_write,
         };
-        let (git_writable, git_protected) = self.git_path_split();
+        let protected_paths = self.protected_paths();
         // Build the writable binds as (captured fd, bind path) pairs in lockstep.
         // The bind *path* is derived from the pinned inode (readlink of the
         // captured `O_PATH` fd), never from an attacker-influenceable string; the
@@ -815,7 +904,7 @@ impl Sandbox {
         // its path on the validator side.
         let mut writable_owned: Vec = Vec::new();
         let mut writable_fds: Vec = Vec::new();
-        for location in self.fs.writable_paths.iter().chain(git_writable.iter()) {
+        for location in &self.fs.writable_paths {
             let Some(path) = linux_location_path(location) else {
                 continue;
             };
@@ -835,11 +924,11 @@ impl Sandbox {
             }
         }
         let writable: Vec<&Path> = writable_owned.iter().map(PathBuf::as_path).collect();
-        let protected_owned: Vec = git_protected
+        let protected_owned: Vec = protected_paths
             .iter()
             .filter_map(linux_location_path)
             .collect();
-        let protected_git_dirs: Vec<&Path> = protected_owned.iter().map(PathBuf::as_path).collect();
+        let protected_paths: Vec<&Path> = protected_owned.iter().map(PathBuf::as_path).collect();
 
         // Stand up the host endpoint that sends the captured fds to the
         // in-sandbox validator, when this run has writable binds to verify. It's
@@ -876,7 +965,7 @@ impl Sandbox {
             bridge_program,
             permissions,
             &writable,
-            &protected_git_dirs,
+            &protected_paths,
             command.cwd.as_deref(),
             &command.program,
             &command.args,
@@ -910,23 +999,18 @@ impl Sandbox {
             network,
             allow_fs_write: self.fs.allow_fs_write,
         };
-        let (git_writable, git_protected) = self.git_path_split();
+        let protected_paths = self.protected_paths();
         // Each location's canonical path was resolved exactly once at capture
         // time; pass it straight through as the Seatbelt rule literal. The
         // profile generator must NOT re-canonicalize (that reopened the
         // verify-vs-enforce gap); see `generate_seatbelt_config`.
-        let mut writable: Vec<&Path> = self
+        let writable: Vec<&Path> = self
             .fs
             .writable_paths
             .iter()
             .map(HostFilesystemLocation::macos_canonical_path)
             .collect();
-        writable.extend(
-            git_writable
-                .iter()
-                .map(HostFilesystemLocation::macos_canonical_path),
-        );
-        let protected: Vec<&Path> = git_protected
+        let protected: Vec<&Path> = protected_paths
             .iter()
             .map(HostFilesystemLocation::macos_canonical_path)
             .collect();
@@ -972,12 +1056,8 @@ impl Sandbox {
             .iter()
             .map(|location| location.windows_path().to_path_buf())
             .collect();
-        let (writable_git_locations, protected_git_locations) = self.git_path_split();
-        let writable_git_paths: Vec = writable_git_locations
-            .iter()
-            .map(|location| location.windows_path().to_path_buf())
-            .collect();
-        let protected_git_paths: Vec = protected_git_locations
+        let protected_paths: Vec = self
+            .protected_paths()
             .iter()
             .map(|location| location.windows_path().to_path_buf())
             .collect();
@@ -985,8 +1065,7 @@ impl Sandbox {
             command.program.clone(),
             command.args.clone(),
             writable_paths,
-            writable_git_paths,
-            protected_git_paths,
+            protected_paths,
             permissions,
             command.cwd.clone(),
             command.env.clone(),
@@ -1231,6 +1310,61 @@ fn map_anyhow_error(error: anyhow::Error) -> SandboxError {
 mod tests {
     use super::*;
 
+    #[test]
+    fn restricted_fs_rejects_writable_paths_inside_protected_paths() {
+        let temp_dir = tempfile::tempdir().expect("create temp dir");
+        let repo_dir = temp_dir.path().join("repo");
+        let git_dir = repo_dir.join(".git");
+        let hooks_dir = git_dir.join("hooks");
+        std::fs::create_dir_all(&hooks_dir).expect("create git hooks dir");
+
+        let protected_git = HostFilesystemLocation::new(&git_dir).expect("capture git dir");
+        let writable_git = HostFilesystemLocation::new(&git_dir).expect("capture git dir");
+        let writable_hooks = HostFilesystemLocation::new(&hooks_dir).expect("capture hooks dir");
+
+        for writable_path in [writable_git, writable_hooks] {
+            let result = Sandbox::new(SandboxPolicy {
+                fs: SandboxFsPolicy::Restricted {
+                    writable_paths: vec![writable_path],
+                    protected_paths: vec![protected_git.clone()],
+                },
+                network: SandboxNetPolicy::Blocked,
+            });
+
+            assert!(matches!(result, Err(SandboxError::InvalidRequest(_))));
+        }
+    }
+
+    #[cfg(target_os = "linux")]
+    #[test]
+    fn restricted_fs_protected_overlap_check_uses_captured_fds_not_path_text() {
+        use std::os::unix::fs as unix_fs;
+
+        let temp_dir = tempfile::tempdir().expect("create temp dir");
+        let repo_dir = temp_dir.path().join("repo");
+        let git_dir = repo_dir.join(".git");
+        let real_git_dir = repo_dir.join("real-git");
+        let outside_dir = temp_dir.path().join("outside");
+        let outside_hooks_dir = outside_dir.join("hooks");
+        std::fs::create_dir_all(&git_dir).expect("create git dir");
+        std::fs::create_dir_all(&outside_hooks_dir).expect("create outside hooks dir");
+
+        let protected_git = HostFilesystemLocation::new(&git_dir).expect("capture git dir");
+        std::fs::rename(&git_dir, &real_git_dir).expect("move git dir aside");
+        unix_fs::symlink(&outside_dir, &git_dir).expect("replace git dir with symlink");
+        let writable_displayed_inside_git =
+            HostFilesystemLocation::new(git_dir.join("hooks")).expect("capture symlink target");
+
+        Sandbox::new(SandboxPolicy {
+            fs: SandboxFsPolicy::Restricted {
+                writable_paths: vec![writable_displayed_inside_git],
+                protected_paths: vec![protected_git],
+            },
+            network: SandboxNetPolicy::Blocked,
+        })
+        .expect("captured writable fd is outside protected metadata");
+    }
+
     #[test]
     fn fs_merge_unrestricted_dominates_else_unions_paths() {
         // Writable paths are captured as real `HostFilesystemLocation`s (keyed on
@@ -1238,31 +1372,45 @@ mod tests {
         let dir_a = tempfile::tempdir().expect("create temp dir a");
         let dir_b = tempfile::tempdir().expect("create temp dir b");
         let dir_c = tempfile::tempdir().expect("create temp dir c");
+        let dir_d = tempfile::tempdir().expect("create temp dir d");
         let location = |dir: &tempfile::TempDir| {
             HostFilesystemLocation::new(dir.path()).expect("capture temp dir")
         };
 
         let a = SandboxFsPolicy::Restricted {
             writable_paths: vec![location(&dir_a), location(&dir_b)],
+            protected_paths: vec![location(&dir_c)],
         };
         let b = SandboxFsPolicy::Restricted {
             writable_paths: vec![location(&dir_b), location(&dir_c)],
+            protected_paths: vec![location(&dir_c), location(&dir_d)],
         };
         assert_eq!(
             a.clone().merge(b),
             SandboxFsPolicy::Restricted {
                 writable_paths: vec![location(&dir_a), location(&dir_b), location(&dir_c)],
+                protected_paths: vec![location(&dir_c), location(&dir_d)],
             }
         );
         assert_eq!(
-            a.merge(SandboxFsPolicy::Unrestricted),
-            SandboxFsPolicy::Unrestricted
+            a.merge(SandboxFsPolicy::Unrestricted {
+                protected_paths: vec![location(&dir_a)],
+            }),
+            SandboxFsPolicy::Unrestricted {
+                protected_paths: vec![location(&dir_c), location(&dir_a)],
+            }
         );
         assert_eq!(
-            SandboxFsPolicy::Unrestricted.merge(SandboxFsPolicy::Restricted {
+            SandboxFsPolicy::Unrestricted {
+                protected_paths: vec![location(&dir_d)],
+            }
+            .merge(SandboxFsPolicy::Restricted {
                 writable_paths: vec![location(&dir_a)],
+                protected_paths: vec![location(&dir_c)],
             }),
-            SandboxFsPolicy::Unrestricted
+            SandboxFsPolicy::Unrestricted {
+                protected_paths: vec![location(&dir_d), location(&dir_c)],
+            }
         );
     }
 
@@ -1343,8 +1491,8 @@ mod tests {
 /// doesn't exist yet.
 ///
 /// `std::fs::canonicalize` fails if any component is missing, which would leave
-/// a not-yet-created path (e.g. a `.git` directory before `git init`) in a
-/// non-canonical form. The sandbox layers canonicalize the writable parent
+/// a not-yet-created path in a non-canonical form. The sandbox layers
+/// canonicalize the writable parent
 /// (the worktree root) but, with a plain `canonicalize`, fall back to the raw
 /// path for a missing child; the two then disagree when a component is a
 /// symlink (`/tmp` -> `/private/tmp` on macOS), and the protection rule for the
@@ -1353,9 +1501,9 @@ mod tests {
 /// consistent with its parent. If neither the path nor its parent can be
 /// canonicalized, the path is returned unchanged.
 //
-// Only the macOS Seatbelt layer uses this (Linux skips not-yet-existing `.git`
-// dirs rather than emitting a rule for them), so it's gated to macOS to avoid a
-// dead-code warning elsewhere.
+// Only the macOS Seatbelt layer uses this (Linux skips not-yet-existing
+// protected paths rather than emitting a rule for them), so it's gated to macOS
+// to avoid a dead-code warning elsewhere.
 #[cfg(target_os = "macos")]
 pub(crate) fn canonicalize_allowing_missing_leaf(path: &std::path::Path) -> std::path::PathBuf {
     if let Ok(canonical) = path.canonicalize() {
@@ -1386,7 +1534,7 @@ mod macos_tests {
 
         // A path whose leaf doesn't exist yet still resolves through its parent,
         // so it stays consistent with how the parent directory canonicalizes
-        // (this is the `.git`-before-`git init` case).
+        // (for example, when protecting a not-yet-created child path).
         let missing = dir.path().join("not-created-yet");
         assert_eq!(
             canonicalize_allowing_missing_leaf(&missing),
diff --git a/crates/sandbox/src/windows_wsl.rs b/crates/sandbox/src/windows_wsl.rs
index 212df7d47c28fd..2979a98852b1b7 100644
--- a/crates/sandbox/src/windows_wsl.rs
+++ b/crates/sandbox/src/windows_wsl.rs
@@ -236,9 +236,9 @@ impl PathMapping {
 /// into a Linux shell invocation (typically `/bin/sh -c ...`) before calling
 /// this function.
 ///
-/// All writable paths, Git paths, and the cwd must be paths that can be mapped
-/// into WSL. The cwd and ordinary writable paths must exist; Git metadata paths
-/// may be missing because Bubblewrap cannot bind a missing source. WSL UNC paths
+/// All writable paths, protected paths, and the cwd must be paths that can be
+/// mapped into WSL. The cwd and writable paths must exist; protected paths may
+/// be missing because Bubblewrap cannot bind a missing source. WSL UNC paths
 /// may specify a distro; native drive-letter paths are translated with `wslpath`
 /// inside either that distro or the default distro (falling back to
 /// `/mnt//...` if translation fails).
@@ -267,8 +267,7 @@ pub async fn wrap_invocation(
     program: String,
     args: Vec,
     writable_paths: Vec,
-    writable_git_paths: Vec,
-    protected_git_paths: Vec,
+    protected_paths: Vec,
     permissions: SandboxPermissions,
     cwd: Option,
     env: HashMap,
@@ -299,35 +298,18 @@ pub async fn wrap_invocation(
             })
         })
         .collect::>>()?;
-    let writable_git_mappings = writable_git_paths
+    let protected_mappings = protected_paths
         .iter()
         .map(|path| {
             path_to_wsl_allowing_missing(path).with_context(|| {
-                format!(
-                    "failed to map writable Git metadata path `{}` into WSL",
-                    path.display()
-                )
-            })
-        })
-        .collect::>>()?;
-    let protected_git_mappings = protected_git_paths
-        .iter()
-        .map(|path| {
-            path_to_wsl_allowing_missing(path).with_context(|| {
-                format!(
-                    "failed to map protected Git metadata path `{}` into WSL",
-                    path.display()
-                )
+                format!("failed to map protected path `{}` into WSL", path.display())
             })
         })
         .collect::>>()?;
 
     let distro = select_distro(
         cwd_mapping.as_ref(),
-        writable_mappings
-            .iter()
-            .chain(writable_git_mappings.iter())
-            .chain(protected_git_mappings.iter()),
+        writable_mappings.iter().chain(protected_mappings.iter()),
     )?;
     let wsl_exe = wsl_exe_path();
     if !wsl_exe.is_file() {
@@ -340,14 +322,11 @@ pub async fn wrap_invocation(
 
     // Resolve all paths (translating native drive-letter paths with `wslpath`
     // now that the distro is known) in a single WSL round-trip. The cwd and
-    // ordinary writable paths must exist; Git paths may be missing because, as
-    // with Linux bwrap, a not-yet-created `.git` placeholder can't be overlaid.
+    // ordinary writable paths must exist; protected paths may be missing because,
+    // as with Linux bwrap, a not-yet-created path can't be overlaid.
     let has_cwd = cwd_mapping.is_some();
     let writable_path_count = writable_mappings.len();
-    let writable_git_path_count = writable_git_mappings.len();
-    let mut mappings = Vec::with_capacity(
-        writable_mappings.len() + writable_git_mappings.len() + protected_git_mappings.len() + 1,
-    );
+    let mut mappings = Vec::with_capacity(writable_mappings.len() + protected_mappings.len() + 1);
     if let Some(mapping) = cwd_mapping {
         mappings.push((mapping, "terminal cwd", true));
     }
@@ -357,22 +336,13 @@ pub async fn wrap_invocation(
             .map(|mapping| (mapping, "writable path", true)),
     );
     mappings.extend(
-        writable_git_mappings
-            .into_iter()
-            .map(|mapping| (mapping, "writable Git metadata path", false)),
-    );
-    mappings.extend(
-        protected_git_mappings
+        protected_mappings
             .into_iter()
-            .map(|mapping| (mapping, "protected Git metadata path", false)),
+            .map(|mapping| (mapping, "protected path", false)),
     );
     let resolved = resolve_paths(&wsl_exe, distro.as_deref(), &mappings).await?;
-    let (cwd, writable_paths, protected_git_paths) = split_resolved_paths(
-        has_cwd,
-        writable_path_count,
-        writable_git_path_count,
-        resolved,
-    )?;
+    let (cwd, writable_paths, protected_paths) =
+        split_resolved_paths(has_cwd, writable_path_count, resolved)?;
 
     let mut wsl_args = Vec::new();
     if let Some(distro) = distro.as_deref() {
@@ -387,7 +357,7 @@ pub async fn wrap_invocation(
     // namespace flags. Identical whether or not we route through the helper.
     let bwrap_options = build_bwrap_args(
         &writable_paths,
-        &protected_git_paths,
+        &protected_paths,
         permissions,
         cwd.as_deref(),
         environment.mask_interop_dir,
@@ -432,7 +402,6 @@ pub async fn wrap_invocation(
 fn split_resolved_paths(
     has_cwd: bool,
     writable_path_count: usize,
-    writable_git_path_count: usize,
     resolved: Vec>,
 ) -> Result<(Option, Vec, Vec)> {
     let mut resolved = resolved.into_iter();
@@ -447,7 +416,7 @@ fn split_resolved_paths(
         None
     };
 
-    let mut writable_paths = Vec::with_capacity(writable_path_count + writable_git_path_count);
+    let mut writable_paths = Vec::with_capacity(writable_path_count);
     for _ in 0..writable_path_count {
         writable_paths.push(
             resolved
@@ -456,14 +425,6 @@ fn split_resolved_paths(
                 .context("bug: required writable path resolved as missing")?,
         );
     }
-    for _ in 0..writable_git_path_count {
-        if let Some(path) = resolved
-            .next()
-            .context("bug: missing resolved writable Git metadata path")?
-        {
-            writable_paths.push(path);
-        }
-    }
 
     Ok((cwd, writable_paths, resolved.flatten().collect()))
 }
@@ -1029,7 +990,7 @@ fn wsl_exe_path() -> PathBuf {
 
 fn build_bwrap_args(
     writable_paths: &[String],
-    protected_git_paths: &[String],
+    protected_paths: &[String],
     permissions: SandboxPermissions,
     cwd: Option<&str>,
     mask_interop_dir: bool,
@@ -1045,12 +1006,12 @@ fn build_bwrap_args(
         for path in writable_paths {
             push_bind(&mut args, "--bind", path, path);
         }
-        // Protect Git metadata by re-binding it read-only over the writable
-        // worktree binds above (order matters: later binds win). When Git access
-        // is granted these paths are included in `writable_paths` instead.
-        for path in protected_git_paths {
-            push_bind(&mut args, "--ro-bind", path, path);
-        }
+    }
+
+    // Protect requested paths by re-binding them read-only over any writable
+    // binds above (order matters: later binds win).
+    for path in protected_paths {
+        push_bind(&mut args, "--ro-bind", path, path);
     }
 
     // Block WSL's Windows interop, regardless of the requested permissions.
@@ -1320,7 +1281,6 @@ mod tests {
             Vec::new(),
             Vec::new(),
             Vec::new(),
-            Vec::new(),
             SandboxPermissions::default(),
             None,
             HashMap::::new(),
@@ -1462,39 +1422,27 @@ mod tests {
     }
 
     #[test]
-    fn split_resolved_paths_keeps_existing_writable_git_paths_and_skips_missing_ones() {
-        let (cwd, writable_paths, protected_git_paths) = split_resolved_paths(
+    fn split_resolved_paths_keeps_existing_protected_paths_and_skips_missing_ones() {
+        let (cwd, writable_paths, protected_paths) = split_resolved_paths(
             true,
             1,
-            2,
             vec![
                 Some("/home/me/project".to_string()),
                 Some("/home/me/project".to_string()),
                 None,
-                Some("/home/me/project/.git".to_string()),
-                None,
                 Some("/mnt/c/external/.git".to_string()),
             ],
         )
         .unwrap();
 
         assert_eq!(cwd.as_deref(), Some("/home/me/project"));
-        assert_eq!(
-            writable_paths,
-            vec![
-                "/home/me/project".to_string(),
-                "/home/me/project/.git".to_string()
-            ]
-        );
-        assert_eq!(
-            protected_git_paths,
-            vec!["/mnt/c/external/.git".to_string()]
-        );
+        assert_eq!(writable_paths, vec!["/home/me/project".to_string()]);
+        assert_eq!(protected_paths, vec!["/mnt/c/external/.git".to_string()]);
     }
 
     #[test]
     fn split_resolved_paths_rejects_missing_required_writable_paths() {
-        let error = split_resolved_paths(false, 1, 0, vec![None]).unwrap_err();
+        let error = split_resolved_paths(false, 1, vec![None]).unwrap_err();
         assert!(
             error
                 .to_string()
@@ -1555,7 +1503,7 @@ mod tests {
     }
 
     #[test]
-    fn bwrap_protects_git_paths_after_writable_paths() {
+    fn bwrap_protects_paths_after_writable_paths() {
         let args = build_bwrap_args(
             &["/home/me/project".to_string()],
             &["/home/me/project/.git".to_string()],
@@ -1578,12 +1526,12 @@ mod tests {
                         "/home/me/project/.git",
                     ]
             })
-            .expect("Git metadata should be protected read-only");
+            .expect("protected path should be bound read-only");
         assert!(protected_index > writable_index);
     }
 
     #[test]
-    fn bwrap_skips_git_protection_when_fs_writes_are_unrestricted() {
+    fn bwrap_protects_paths_when_fs_writes_are_unrestricted() {
         let args = build_bwrap_args(
             &[],
             &["/home/me/project/.git".to_string()],
@@ -1595,12 +1543,22 @@ mod tests {
             true,
             &HashMap::new(),
         );
-        assert!(!args.windows(3).any(|window| window
-            == [
-                "--ro-bind",
-                "/home/me/project/.git",
-                "/home/me/project/.git"
-            ]));
+        let unrestricted_write_index = args
+            .windows(3)
+            .position(|window| window == ["--bind", "/", "/"])
+            .expect("root should be writable");
+        let protected_index = args
+            .windows(3)
+            .position(|window| {
+                window
+                    == [
+                        "--ro-bind",
+                        "/home/me/project/.git",
+                        "/home/me/project/.git",
+                    ]
+            })
+            .expect("protected path should be bound read-only");
+        assert!(protected_index > unrestricted_write_index);
     }
 
     #[test]
diff --git a/crates/sandbox/src/wsl_sandbox_test_helper.rs b/crates/sandbox/src/wsl_sandbox_test_helper.rs
index d184d09ef42f5f..6f46e87f45100c 100644
--- a/crates/sandbox/src/wsl_sandbox_test_helper.rs
+++ b/crates/sandbox/src/wsl_sandbox_test_helper.rs
@@ -48,8 +48,8 @@ mod imp {
 
     use anyhow::{Context as _, Result, bail, ensure};
     use sandbox::{
-        CommandAndArgs, GitSandboxPolicy, HostFilesystemLocation, Sandbox, SandboxError,
-        SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy,
+        CommandAndArgs, HostFilesystemLocation, Sandbox, SandboxError, SandboxFsPolicy,
+        SandboxNetPolicy, SandboxPolicy,
     };
 
     /// Network access for a helper run, translated into a `SandboxNetPolicy` in
@@ -510,7 +510,9 @@ mod imp {
     ) -> Result {
         let policy = SandboxPolicy {
             fs: if permissions.allow_fs_write {
-                SandboxFsPolicy::Unrestricted
+                SandboxFsPolicy::Unrestricted {
+                    protected_paths: Vec::new(),
+                }
             } else {
                 SandboxFsPolicy::Restricted {
                     // On Windows the location only records the requested path;
@@ -519,13 +521,13 @@ mod imp {
                         .iter()
                         .filter_map(|path| HostFilesystemLocation::new(path).ok())
                         .collect(),
+                    protected_paths: Vec::new(),
                 }
             },
             network: match permissions.network {
                 NetworkAccess::None => SandboxNetPolicy::Blocked,
                 NetworkAccess::All => SandboxNetPolicy::Unrestricted,
             },
-            git: GitSandboxPolicy::default(),
         };
         let command = CommandAndArgs {
             program: program.to_string(),
diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs
index 55bb90dd5c70cb..0d27b3f9a84a96 100644
--- a/crates/settings_content/src/agent.rs
+++ b/crates/settings_content/src/agent.rs
@@ -486,12 +486,6 @@ impl AgentSettingsContent {
             .allow_fs_write_all = Some(true);
     }
 
-    pub fn allow_sandbox_git_access(&mut self) {
-        self.sandbox_permissions
-            .get_or_insert_default()
-            .allow_git_access = Some(true);
-    }
-
     pub fn allow_sandbox_unsandboxed(&mut self) {
         self.sandbox_permissions
             .get_or_insert_default()
@@ -800,11 +794,6 @@ pub struct SandboxPermissionsContent {
     /// Default: []
     pub network_hosts: Option>,
 
-    /// Whether sandboxed terminal commands may always access protected Git
-    /// metadata paths without prompting.
-    /// Default: false
-    pub allow_git_access: Option,
-
     /// Whether sandboxed terminal commands may always write anywhere on the
     /// filesystem without prompting.
     /// Default: false
@@ -1145,7 +1134,6 @@ mod tests {
             &["github.com".to_string(), "*.npmjs.org".to_string()]
         );
         settings.allow_sandbox_fs_write_all();
-        settings.allow_sandbox_git_access();
         settings.allow_sandbox_unsandboxed();
         settings.add_sandbox_write_path(PathBuf::from("/tmp/build"));
 
@@ -1160,7 +1148,6 @@ mod tests {
                 .as_slice(),
             &["github.com".to_string(), "*.npmjs.org".to_string()]
         );
-        assert_eq!(sandbox_permissions.allow_git_access, Some(true));
         assert_eq!(sandbox_permissions.allow_fs_write_all, Some(true));
         assert_eq!(sandbox_permissions.allow_unsandboxed, Some(true));
         assert_eq!(
diff --git a/crates/settings_ui/src/pages/sandbox_settings.rs b/crates/settings_ui/src/pages/sandbox_settings.rs
index 9ae15eb738e95a..da69fdd5761d16 100644
--- a/crates/settings_ui/src/pages/sandbox_settings.rs
+++ b/crates/settings_ui/src/pages/sandbox_settings.rs
@@ -12,8 +12,7 @@ use crate::components::{SettingsInputField, SettingsSectionHeader};
 
 const DOMAINS_DESCRIPTION: &str = "Each entry is an exact domain (github.com) or a leading-*. subdomain wildcard (*.npmjs.org). IP addresses and local domains are not allowed.";
 
-const WRITE_PATHS_DESCRIPTION: &str =
-    "Each entry must be an absolute path and grants write access to the whole subtree.";
+const WRITE_PATHS_DESCRIPTION: &str = "Each entry must be an absolute path and grants write access to the whole subtree, except protected Git metadata.";
 
 pub(crate) fn render_sandbox_settings_page(
     settings_window: &SettingsWindow,
@@ -117,27 +116,7 @@ pub(crate) fn render_sandbox_settings_page(
                     empty_border,
                 )),
         )
-        .child(Divider::horizontal())
-        .child(
-            v_flex()
-                .gap_4()
-                .child(SettingsSectionHeader::new("Git").no_padding(true))
-                .child(
-                    SwitchField::new(
-                        "sandbox-allow-git-access",
-                        Some("Allow Git Metadata Access"),
-                        Some(
-                            "Let sandboxed commands access protected Git metadata, including .git directories and linked worktree metadata, without prompting."
-                                .into(),
-                        ),
-                        permissions.allow_git_access,
-                        move |state, _window, cx| {
-                            set_allow_git_access(*state == ToggleState::Selected, cx);
-                        },
-                    )
-                    .tab_index(0),
-                ),
-        )
+
         .child(Divider::horizontal())
         .child(
             v_flex()
@@ -148,7 +127,7 @@ pub(crate) fn render_sandbox_settings_page(
                         "sandbox-allow-fs-write-all",
                         Some("Allow All File System Writes"),
                         Some(
-                            "Let sandboxed commands write anywhere on the file system without prompting."
+                            "Let sandboxed commands write anywhere except protected Git metadata without prompting."
                                 .into(),
                         ),
                         permissions.allow_fs_write_all,
@@ -433,12 +412,6 @@ fn set_allow_all_hosts(value: bool, cx: &mut App) {
     });
 }
 
-fn set_allow_git_access(value: bool, cx: &mut App) {
-    update_sandbox_permissions(cx, move |permissions| {
-        permissions.allow_git_access = Some(value);
-    });
-}
-
 fn set_allow_fs_write_all(value: bool, cx: &mut App) {
     update_sandbox_permissions(cx, move |permissions| {
         permissions.allow_fs_write_all = Some(value);
diff --git a/docs/.doc-examples/reference.md b/docs/.doc-examples/reference.md
index 35fda196f862ea..386bd3fc54752e 100644
--- a/docs/.doc-examples/reference.md
+++ b/docs/.doc-examples/reference.md
@@ -40,6 +40,8 @@ When no path is provided, shows a summary of error and warning counts for all fi
 
 Fetches a URL and returns the content as Markdown. Useful for providing docs as context.
 
+`fetch` is governed by tool permissions, agent profiles, and project trust. It is not run inside the terminal OS sandbox, so terminal sandbox network grants such as `allow_hosts` and `allow_all_hosts` do not apply to it.
+
 ### `find_path` {#find-path}
 
 Quickly finds files by matching glob patterns (like `**/*.js`), returning matching file paths alphabetically.
diff --git a/docs/src/ai/sandboxing.md b/docs/src/ai/sandboxing.md
index 2b77863594b178..715e331da7fade 100644
--- a/docs/src/ai/sandboxing.md
+++ b/docs/src/ai/sandboxing.md
@@ -9,10 +9,9 @@ You can restrict what operations the [Zed Agent](./zed-agent.md) can run in mult
 [Tool Permissions](./tool-permissions.md), but these are of limited use when the agent wants to do things like run a
 complicated script in a terminal.
 
-Sandboxing runs certain tool actions in an OS-level sandbox which limits filesystem access, network access, and access to
-protected Git metadata. This way, even if the agent wants to run an arbitrary script, that script will only be able to
-write to the files and folders you have allowed it to. You can similarly restrict network and Git metadata access in
-sandboxed tool actions.
+Sandboxing runs certain tool actions in an OS-level sandbox which limits filesystem access and network access, while
+protecting Git metadata such as `.git` directories. This way, even if the agent wants to run an arbitrary script, that
+script will only be able to write to the files and folders you have allowed it to.
 
 [Tool Permissions](./tool-permissions.md) can be used in addition to sandboxing:
 
@@ -26,9 +25,9 @@ terminal tabs, [External Agents](./external-agents.md), or [Terminal Threads](./
 
 Zed Agent sandboxing currently applies to the `terminal` tool.
 
-| Tool       | What sandboxing limits                                                                              |
-| ---------- | --------------------------------------------------------------------------------------------------- |
-| `terminal` | Filesystem writes, protected Git metadata, and outbound network access for commands the agent runs. |
+| Tool       | What sandboxing limits                                                                                |
+| ---------- | ----------------------------------------------------------------------------------------------------- |
+| `terminal` | Filesystem writes and outbound network access for commands the agent runs; Git metadata is protected. |
 
 Other built-in tools, including `fetch`, are still governed by [Tool Permissions](./tool-permissions.md),
 [Agent Profiles](./agent-profiles.md), and project trust, but they are not currently run inside this OS sandbox.
@@ -39,9 +38,9 @@ By default, sandboxed Zed Agent tool actions have these restrictions:
 
 | Access type         | Default behavior                                                                                                                                                    |
 | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Filesystem reads    | Terminal commands can read most of the filesystem. Protected Git metadata file contents are hidden on macOS; on Linux and Windows/WSL they remain readable.         |
+| Filesystem reads    | Terminal commands can read most of the filesystem, including protected Git metadata.                                                                                |
 | Project writes      | Terminal commands can write inside open project directories, except for protected Git metadata.                                                                     |
-| Git metadata        | `.git` directories and linked worktree Git metadata are protected unless you approve Git metadata access. Protection details differ by platform, described below.   |
+| Git metadata        | `.git` directories and linked worktree Git metadata remain readable but are not writable while sandboxed.                                                           |
 | Temporary files     | Terminal commands receive a writable temporary location. The exact behavior differs by platform.                                                                    |
 | Other writes        | Writes outside the default writable locations are blocked unless you approve a broader sandbox request.                                                             |
 | Outbound networking | Network access is blocked unless you approve a host-specific or unrestricted network sandbox request. Host-specific enforcement is not available on every platform. |
@@ -53,9 +52,8 @@ Depending on what the tool requested, the prompt can ask you to allow:
 
 - network access to specific hosts, such as `github.com` or `*.npmjs.org`
 - network access to any host
-- access to protected Git metadata, such as `.git` directories and linked worktree metadata
 - write access to specific filesystem paths
-- unrestricted filesystem writes
+- unrestricted filesystem writes, except protected Git metadata
 - running a terminal command without the sandbox
 
 You can grant a sandbox request for:
@@ -76,7 +74,6 @@ If you want to pre-approve common sandbox requests, add persistent permissions t
   "agent": {
     "sandbox_permissions": {
       "network_hosts": ["github.com", "*.npmjs.org"],
-      "allow_git_access": true,
       "write_paths": ["/Users/you/.cache/my-tool"]
     }
   }
@@ -89,13 +86,22 @@ The available options are:
 | -------------------- | ----------------------------------------------------------------------------------------------------------------- |
 | `network_hosts`      | Hosts that sandboxed tools may reach without prompting. Entries can be exact hostnames or leading-`*.` wildcards. |
 | `allow_all_hosts`    | Allow sandboxed tools to reach any host without prompting.                                                        |
-| `allow_git_access`   | Allow sandboxed terminal commands to access protected Git metadata without prompting.                             |
 | `write_paths`        | Directory subtrees that sandboxed terminal commands may write to without prompting. Paths are absolute.           |
-| `allow_fs_write_all` | Allow sandboxed terminal commands to write anywhere without prompting.                                            |
+| `allow_fs_write_all` | Allow sandboxed terminal commands to write anywhere except protected Git metadata without prompting.              |
 | `allow_unsandboxed`  | Allow terminal commands to run outside the sandbox without prompting when the agent explicitly requests it.       |
 
-Prefer narrow grants, such as a specific host, Git metadata access, or write path, over `allow_all_hosts`,
-`allow_fs_write_all`, or `allow_unsandboxed`.
+Prefer narrow grants, such as a specific host or write path, over `allow_all_hosts`, `allow_fs_write_all`, or
+`allow_unsandboxed`.
+
+## Git Metadata {#git-metadata}
+
+Git metadata writes are not grantable while a terminal command is sandboxed. This includes writes to `.git` directories,
+linked worktree metadata, refs, the index, hooks, local Git config, and other Git-controlled metadata files. Approving a
+specific writable path or `allow_fs_write_all` does not make Git metadata writable.
+
+When a Git command genuinely needs to update Git metadata, such as `git commit`, `git fetch`, `git checkout`, or `git
+rebase`, approve unsandboxed execution instead. For read-only operations, prefer Git flags that avoid optional metadata
+writes when possible. For example, use `git --no-optional-locks status` instead of `git status`.
 
 ## Platform Support {#platform-support}
 
@@ -111,7 +117,8 @@ Sandboxed terminal commands:
 - can read the filesystem
 - can write inside open project directories, except protected Git metadata
 - can write to a per-thread temporary directory exposed through `$TMPDIR`, `$TMP`, and `$TEMP`
-- cannot read or write protected Git metadata unless you approve Git metadata access
+- can read protected Git metadata
+- cannot write protected Git metadata, even if you approve broader write access
 - cannot write elsewhere unless you approve additional paths or broader write access
 - cannot reach the network unless you approve network access
 
@@ -133,7 +140,7 @@ Sandboxed terminal commands:
 - can read the filesystem, including protected Git metadata contents
 - can write inside open project directories, except protected Git metadata
 - can write to `/tmp`, which is backed by a fresh temporary filesystem and is cleared between terminal tool calls
-- cannot write protected Git metadata unless you approve Git metadata access
+- cannot write protected Git metadata
 - cannot write elsewhere unless you approve additional paths or broader write access
 - cannot reach the network unless you approve network access
 
@@ -155,9 +162,9 @@ command cannot be confined by Zed Agent's OS sandbox in the same way.
 When running inside WSL, the Linux sandboxing behavior applies, including the requirement that `bwrap` not be setuid-root:
 
 - filesystem isolation is provided by Bubblewrap
-- protected Git metadata contents remain readable, but writes are blocked unless you approve Git metadata access
+- protected Git metadata contents remain readable, but writes are blocked
 - `/tmp` is temporary for sandboxed terminal calls
-- network access is all-or-nothing rather than host-specific
+- network access is all-or-nothing rather than host-specific, so host-specific network requests are rejected and the agent must request unrestricted network access when network access is needed
 
 If WSL is not installed, or if you choose to run a command without the sandbox, Zed falls back to the standard terminal
 behavior of running in your native shell. It selects the shell using the usual preference order: Git Bash (or scoop's
@@ -170,8 +177,6 @@ rather than WSL's `/mnt/c/...`), so a command written for the sandboxed WSL shel
 When reviewing a sandbox prompt, prefer the narrowest permission that lets the task proceed:
 
 - approve a specific host instead of all hosts when the destination is known
-- approve Git metadata access when the command needs to run Git operations such as `git fetch`, `git commit`, or
-  `git status`
 - approve a specific write path instead of unrestricted filesystem writes
 - approve unsandboxed execution only when the command cannot work inside the sandbox
 - use one-time approvals for unfamiliar commands
diff --git a/docs/src/ai/tools.md b/docs/src/ai/tools.md
index a4753260ccfc8d..3b47da18798385 100644
--- a/docs/src/ai/tools.md
+++ b/docs/src/ai/tools.md
@@ -31,6 +31,8 @@ When no path is provided, shows a summary of error and warning counts for all fi
 
 Fetches a URL and returns the content as Markdown. Useful for providing docs as context.
 
+`fetch` is governed by tool permissions, agent profiles, and project trust. It is not run inside the terminal OS sandbox, so terminal sandbox network grants such as `allow_hosts` and `allow_all_hosts` do not apply to it.
+
 **Example:** Fetching a library's changelog page to check whether a breaking API change was introduced in a recent version before writing integration code.
 
 ### `find_path`
diff --git a/nix/tests/sandboxing/default.nix b/nix/tests/sandboxing/default.nix
index eb4fbad82b0757..185c70bbe4cc5f 100644
--- a/nix/tests/sandboxing/default.nix
+++ b/nix/tests/sandboxing/default.nix
@@ -31,11 +31,8 @@
 #   writablePaths = [ ];        # writable subtrees when fs = "restricted"
 #   networkAccess = "blocked";  # or "unrestricted" / "restricted"
 #   allowedDomains = [ ];       # allowed hosts when networkAccess = "restricted"
-#   gitDisabled = [ ];          # `.git` dirs whose contents are protected
-#                               # (read-only on Linux). Mutually exclusive with
-#                               # gitAllowed.
-#   gitAllowed = [ ];           # `.git` dirs whose contents are made writable.
-#                               # Mutually exclusive with gitDisabled.
+#   protectedPaths = [ ];       # paths that remain readable but not writable,
+#                               # even if they fall under a writable subtree.
 #
 # Two echo servers (`echo1`, `echo2`) on separate nodes give the network checks
 # real peers, so a restricted-network policy that allowlists `echo1` can be
@@ -237,136 +234,49 @@ in
         succeeds = true;
       }
 
-      # ---- Git protection ----------------------------------------------------
-      # The worktree is writable, but the `.git` directory inside it can be
-      # protected (Git disabled) or opened up (Git allowed) independently. On
-      # Linux a protected `.git` is re-bound read-only *over* the writable
-      # worktree, so its contents stay readable but writes are denied.
-
-      # Git disabled: the worktree is writable but `.git` is protected, so a
-      # write into `.git` is denied. This is the core case the feature exists
-      # for — an agent editing the project must not be able to corrupt Git
-      # metadata.
+      # ---- Protected paths ---------------------------------------------------
+      # A path inside a writable subtree can be re-bound read-only over the
+      # writable bind (order matters: later binds win). On Linux, protected paths
+      # remain readable but writes are denied.
       {
         fs = "restricted";
         writablePaths = [ "/sandbox-test/repo" ];
-        gitDisabled = [ "/sandbox-test/repo/.git" ];
+        protectedPaths = [ "/sandbox-test/repo/protected" ];
         networkAccess = "blocked";
-        write = "/sandbox-test/repo/.git/test";
+        write = "/sandbox-test/repo/protected/test";
         succeeds = false;
       }
 
-      # Git allowed: the same write into `.git` now succeeds because the policy
-      # makes `.git` writable.
+      # Protected paths affect only the protected subtree: ordinary writes
+      # elsewhere in the writable worktree are unaffected.
       {
         fs = "restricted";
         writablePaths = [ "/sandbox-test/repo" ];
-        gitAllowed = [ "/sandbox-test/repo/.git" ];
-        networkAccess = "blocked";
-        write = "/sandbox-test/repo/.git/test";
-        succeeds = true;
-      }
-
-      # Git disabled protects only `.git`: ordinary writes elsewhere in the
-      # writable worktree are unaffected.
-      {
-        fs = "restricted";
-        writablePaths = [ "/sandbox-test/repo" ];
-        gitDisabled = [ "/sandbox-test/repo/.git" ];
+        protectedPaths = [ "/sandbox-test/repo/protected" ];
         networkAccess = "blocked";
         write = "/sandbox-test/repo/src/main.rs";
         succeeds = true;
       }
 
-      # Git disabled is a subtree protection: a write to something nested deep
-      # inside `.git` is denied too, not just a top-level file.
+      # Protected paths block writes but NOT reads on Linux because the protected
+      # subtree is read-only.
       {
         fs = "restricted";
         writablePaths = [ "/sandbox-test/repo" ];
-        gitDisabled = [ "/sandbox-test/repo/.git" ];
-        networkAccess = "blocked";
-        write = "/sandbox-test/repo/.git/hooks/pre-commit";
-        succeeds = false;
-      }
-
-      # Git disabled blocks writes but NOT reads: on Linux a protected `.git` is
-      # read-only, so its contents remain readable from inside the sandbox
-      # (Git status, log, diff, … must keep working).
-      {
-        fs = "restricted";
-        writablePaths = [ "/sandbox-test/repo" ];
-        gitDisabled = [ "/sandbox-test/repo/.git" ];
-        networkAccess = "blocked";
-        read = "/sandbox-test/repo/.git/HEAD";
-        succeeds = true;
-      }
-
-      # Git allowed grants writes to `.git` on its own, even when the worktree
-      # itself is not in `writablePaths`. The policy's Git dirs are made
-      # writable directly.
-      {
-        fs = "restricted";
-        writablePaths = [ ];
-        gitAllowed = [ "/sandbox-test/repo/.git" ];
+        protectedPaths = [ "/sandbox-test/repo/protected" ];
         networkAccess = "blocked";
-        write = "/sandbox-test/repo/.git/test";
+        read = "/sandbox-test/repo/protected/HEAD";
         succeeds = true;
       }
 
-      # ...and that grant is scoped to `.git`: it does not make the surrounding
-      # worktree writable. A write next to `.git` (not in `writablePaths`) is
-      # still denied.
-      {
-        fs = "restricted";
-        writablePaths = [ ];
-        gitAllowed = [ "/sandbox-test/repo/.git" ];
-        networkAccess = "blocked";
-        write = "/sandbox-test/repo/README.md";
-        succeeds = false;
-      }
-
-      # Multiple repositories: each protected `.git` is independently denied.
-      # This exercises the list handling — a write into the *second* repo's
-      # `.git` must still be blocked.
-      {
-        fs = "restricted";
-        writablePaths = [
-          "/sandbox-test/repo-a"
-          "/sandbox-test/repo-b"
-        ];
-        gitDisabled = [
-          "/sandbox-test/repo-a/.git"
-          "/sandbox-test/repo-b/.git"
-        ];
-        networkAccess = "blocked";
-        write = "/sandbox-test/repo-b/.git/test";
-        succeeds = false;
-      }
-
-      # The fs escape hatch supersedes Git protection: when filesystem writes
-      # are unrestricted there is no read-only bind to protect `.git`, so the
-      # write succeeds. This documents that `gitDisabled` only has teeth under a
-      # restricted filesystem policy.
+      # Protected paths stay read-only even when ordinary filesystem writes are
+      # otherwise unrestricted.
       {
         fs = "unrestricted";
-        gitDisabled = [ "/sandbox-test/repo/.git" ];
-        networkAccess = "blocked";
-        write = "/sandbox-test/repo/.git/test";
-        succeeds = true;
-      }
-
-      # Documented Linux gap: a `.git` that does not yet exist when the sandbox
-      # is built cannot be re-bound read-only, so it is skipped. Writing the
-      # `.git` entry itself (its parent worktree is writable, and `.git` does
-      # not exist beforehand) therefore succeeds. macOS denies this even before
-      # `.git` exists; bwrap cannot, and this test pins that difference.
-      {
-        fs = "restricted";
-        writablePaths = [ "/sandbox-test/fresh-repo" ];
-        gitDisabled = [ "/sandbox-test/fresh-repo/.git" ];
+        protectedPaths = [ "/sandbox-test/repo/protected" ];
         networkAccess = "blocked";
-        write = "/sandbox-test/fresh-repo/.git";
-        succeeds = true;
+        write = "/sandbox-test/repo/protected/test";
+        succeeds = false;
       }
 
       # Blocked network: the echo server is unreachable.

From b083358680a69ec982bd3efdcbcd1c90e08d6b2b Mon Sep 17 00:00:00 2001
From: Xin Zhao 
Date: Wed, 1 Jul 2026 23:18:54 +0800
Subject: [PATCH 017/422] 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 710ddf761eb6eb..4f03a47f609442 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 018/422] 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 d3be7e7882d9fd..c3ae43b5a9c606 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 50099f28a4a2ed..2b67736bf0e250 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 019/422] 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 c3ae43b5a9c606..2445366c1264c6 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 8b25804d724350..822b91c3cd19ce 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 7fe2d2c0b9f2db..88d8a5fd86d1bd 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 9da4c5ec6e1cf5..5197dee517a068 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 6511497623a174..ea888d564cc928 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 020/422] 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      | 247 ++++++++++++------
 crates/agent_ui/src/message_editor.rs         | 145 +++++++++-
 crates/icons/src/icons.rs                     |   1 +
 8 files changed, 482 insertions(+), 106 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 36db1414b8cf8f..898af2397c750e 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 785b3aa56d708b..897bef603a182a 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 00000000000000..8320108fa79590
--- /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 88d8a5fd86d1bd..f22037c239af1b 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 d25eae39064cea..ccc95c2fc4ede1 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 fc0f5b96a580ce..858fc5e840eba0 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,65 +6690,30 @@ impl ThreadView {
             })
             .flatten();
 
-        let mut container = h_flex()
-            .w_full()
-            .py_2()
-            .px_5()
-            .gap_px()
-            .opacity(0.6)
-            .hover(|s| s.opacity(1.))
-            .justify_end()
-            .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))
-                            .when_some(last_turn_clock, |this, label| this.child(label)),
-                    )
-                },
-            );
-
-        let enable_thread_feedback = util::maybe!({
-            let project = 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 {
-                    return false;
-                }
-            }
-
-            AgentSettings::get_global(cx).enable_feedback
-                && self.thread.read(cx).connection().telemetry().is_some()
-        });
-
-        if enable_thread_feedback {
-            let feedback = self.thread_feedback.feedback;
-
-            let tooltip_meta = || {
-                SharedString::new(
-                    "Rating the thread sends all of your current conversation to the Zed team.",
-                )
-            };
+        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.";
 
-            container = container
+                h_flex()
                     .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,
+                                _ => 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)
-                                }
+                                _ => 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);
@@ -6709,40 +6721,100 @@ impl ThreadView {
                     )
                     .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,
+                                _ => Color::Muted,
                             })
                             .tooltip(move |window, cx| match feedback {
-                                Some(ThreadFeedback::Negative) => {
-                                    Tooltip::text(
+                                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,
-                                    )
-                                }
+                                )(
+                                    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);
                             })),
-                    );
-        }
+                    )
+            },
+        );
 
-        container
-            .child(open_as_markdown)
+        h_flex()
+            .w_full()
+            .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()
+                            .px_1()
+                            .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()
     }
 
+    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 {
+                    return false;
+                }
+            }
+
+            AgentSettings::get_global(cx).enable_feedback
+                && self.thread.read(cx).connection().telemetry().is_some()
+        })
+    }
+
+    // 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();
+
+        if self.is_thread_feedback_enabled(cx) {
+            commands.push(PromptLocalCommand::ThumbsUp);
+            commands.push(PromptLocalCommand::ThumbsDown);
+        }
+
+        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 {
         let server_view = self.server_view.clone();
         let handlers_view = server_view.clone();
@@ -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 f262e44f506b03..f9878d6c5adff5 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 56182da2133bfb..3e22896e3256e8 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 021/422] 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 8c8c3a36e9a73a..3b90d1e2bc0438 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 12138cb7f92ebe..70b4911e17c8bb 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 00000000000000..125dfa6d9d63eb
--- /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 ab49877c6bb961..b973d8f25c19d8 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 022/422] 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 | 110 ++++++++++---- crates/language/src/buffer_tests.rs | 226 +++++++++++++++++++++++++++- crates/project/src/lsp_store.rs | 53 +++++-- 3 files changed, 349 insertions(+), 40 deletions(-) diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 413c50d2627914..d41143d12f5d89 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; + 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; } - 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 ba7fd7ea957f0b..cdcd90314cd7a5 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 feb1936c47a99f..e7ac11e773bade 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 023/422] 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 ce6d9e6bd9469c..c87c53cbc28db1 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 b410fb4d8cadd8..8b0b160c862179 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 2c08c30ad587ff..19099b0076a87e 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 db1f69a2c22e5a..8b0b160c862179 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 2c08c30ad587ff..19099b0076a87e 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 38fee610e85f2a..b2321b816ca41e 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 5572e43f0492bc..e8d18840e0fe54 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 024/422] 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 2979a98852b1b7..b5cc8694b12fc2 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 025/422] 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 74560dd49bb025..74c6252fe73539 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 89266065a1c040..024314ebdd4a29 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 6c40f210c89392..406c1ead91b39a 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 5deadcc108a6bf..517367af2b8685 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 026/422] 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 6559890c0d3dff..479ef876baf77d 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 027/422] 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 2445366c1264c6..8037aa43f99f79 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 7443803346e2bc..3db8d84b73336f 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 024314ebdd4a29..69f5aa320838e6 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 028/422] 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 1edd3780307ecb..778743163d6516 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 029/422] 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 ef1981db60caa1..059f068507842d 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 d1a0f3b209eb0c..3e4e3788da7041 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 ea3091a6fffbcc..55736c0824b887 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 030/422] 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 38f578b36b8d29..b9882c51354413 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 031/422] 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 8037aa43f99f79..920c4e48122b2d 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 80cc6501001485..9f660975525e31 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 032/422] 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 00000000000000..77f03168c4e4c5 --- /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 00000000000000..babab1c8b9ccb7 --- /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 00000000000000..90b47988c3fd61 --- /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 00000000000000..9f2a7fb4c8ccbf --- /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 fbba166a2446d9..f295dcb4f9a68f 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 688cfdded83a76..ae58a67868d6b2 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 a182de8ac65283..92724e5722f1cc 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 00000000000000..1fe4c34baaf715 --- /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 033/422] 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 90b4a6a5997150..12a3a263d2d6e2 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 600e7aa5932fb6..971581dcf1abbd 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 316bb673a9aa36..c2804f853a359b 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 c4897cd4d05025..e5b2ef47deaab1 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 034/422] 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 70b4911e17c8bb..dbc177e7a9326a 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 0e4cb930dcb1e2..80f6bf7fab9f58 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 035/422] 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 2d3afdac12b82a..90818dbe3131a8 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 de1d6905205a3a..dc50483f4962ea 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 036/422] 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 22b2795145fd20..625e48b0877768 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 037/422] 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 74c6252fe73539..8725587ade6d68 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 75b953e7cbc3d0..f7ad53118d3a61 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 a1d1a43381524c..f0f652d2baa4ad 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 34ee4440729a93..94dff0038f0c78 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 038/422] 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 a06e0268c96f48..0de0a5444d9da2 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 039/422] 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 a992dfe4fd986c..fd0705d5d94028 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 040/422] 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 59fe449b185b1c..746c45d9f826ce 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 041/422] 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 e2f0be07ce0968..8ad37dcfd63ec4 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 dc50483f4962ea..145ceaa6943849 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 042/422] 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 dbc177e7a9326a..36b5209a5c0530 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 145ceaa6943849..4acdae8d2810bd 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 274fabac9a7224..df6a911329be75 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 d91af01a542c2e..389eeb88e618bf 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 043/422] 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 5fd304b73384c1..11461d38ee3793 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 72c8150317341c..323fc10fb018dc 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 044/422] 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 3b8a6e770e4475..e1eddf01c282ce 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 045/422] 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 3fd1d6c87f85be..e24dfb205178e4 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 663153a84357d9..2c4191089abae5 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 707587658ce5b9..500f8e4fe9e1ef 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 0b69e49d044b61..4d4b07174793a8 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 d36587ce46d659..1f120f0e32dc27 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 046/422] 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 | 108 +++++---- .../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, 432 insertions(+), 1899 deletions(-) diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index eaf51978b5d21a..5f0cf69890df0f 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 350d9c2f912569..65632e9406b03e 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 858fc5e840eba0..0f3c18d2245897 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 f58130cccf44f6..a9cb4c5b5fb5c8 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 7a8aa00f92daf2..2e060049d0b056 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,58 +348,69 @@ 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 { + /// 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. + fn fast_mode_confirmation(&self, _cx: &App) -> Option { None } +} - fn inline_description(&self, _cx: &App) -> Option { - None - } +/// A provider's settings UI, modeled as mutually exclusive presentation modes. +#[derive(Clone)] +pub enum ProviderSettingsView { + ApiKey(ApiKeyConfiguration), + Inline(InlineProviderSettings), + SubPage(SubPageProviderSettings), +} - fn api_key_configuration(&self, _cx: &App) -> Option { - None - } +#[derive(Clone)] +pub struct InlineProviderSettings { + pub title: Option, + pub description: Option, + pub create_view: CreateProviderSettingsView, +} - fn set_api_key(&self, _key: String, _cx: &mut App) -> Task> { - Task::ready(Ok(())) +#[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), + } } - /// 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. - fn fast_mode_confirmation(&self, _cx: &App) -> Option { - None + pub fn description(mut self, description: InlineDescription) -> Self { + self.description = Some(description); + self } } -/// How a provider's configuration UI prefers to be presented by the settings UI. -#[derive(Clone)] -pub enum ProviderConfigurationView { - Inline { view: AnyView }, - SubPage(AnyView), +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 1841a9619d2537..19ba2b45678cf0 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 b07900453243d7..8a3ddd85da494a 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 set_api_key(&self, key: String, cx: &mut App) -> Task> { - self.state - .update(cx, |state, cx| state.set_api_key(Some(key), 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| { + ApiCompatibleProviderConfigurationView::new( + state.clone(), + "Anthropic", + API_KEY_PLACEHOLDER, + window, + cx, + ) + }) + .into() + }, + ))) } - fn reset_credentials(&self, 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(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 4d843009f7623c..ee673577c66881 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 926f99eae1b60a..06131314eef347 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 94bc00e72d5c78..db845e85796a82 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 943b3861e3c513..f448ae2be68895 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 8a289fd2e05998..7906e8f301eb93 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 ed207d4b47bbca..19f0e9f2962fd0 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 362db21070ed71..3f6e9fb46e2c43 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 de9f385c8e5284..f12f33a6f35f4e 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 7bbc2ec18cdbe6..f0166c6afb12fe 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 87be0e2751b867..c2c74a8553b29d 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 e0c6f1f16ea1fb..e69e847eab91df 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 set_api_key(&self, key: String, cx: &mut App) -> Task> { - self.state - .update(cx, |state, cx| state.set_api_key(Some(key), 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| { + ApiCompatibleProviderConfigurationView::new( + state.clone(), + "OpenAI", + API_KEY_PLACEHOLDER, + window, + cx, + ) + }) + .into() + }, + ))) } - fn reset_credentials(&self, 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(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 f41e04972d0594..fd5221c9b7ce78 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 2b09032ca515f2..20038e61234904 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 f75c71e425b9a8..94689cc0d66b84 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 dcceffe22e7f0f..212223eb851426 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 92121f2e5ff442..4556e439a39453 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 f888b031eb8f1b..bac9b7abb06ed9 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 34a2ac271d494e..35f816d649b0ed 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 9d47f2724064f7..8b2d0d45df7ee5 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 047/422] 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 36b5209a5c0530..913ab20c4c1f19 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 4acdae8d2810bd..181a4d447ba72f 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 048/422] 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 2642221b31cd80..79c65b227bb942 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 e715e98a655d07..e9c3f22c23cd50 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 049/422] 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 | 107 +++++++++++++++++++--- crates/project_panel/src/tests/undo.rs | 22 +++++ 5 files changed, 134 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 920c4e48122b2d..bc57517ab75020 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 746c45d9f826ce..4c4a39122630e9 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 eac58314f08a70..89e97ee9c78902 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 059f068507842d..bf3b33efc725c2 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; + + 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::>() + }) + })?; - 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?; - - // 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 4315a6ecb4c3d2..1e69c7443ae2de 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 050/422] 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 60e49fc39a6091..d8dad9fec13fa0 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 c0ea34bf3aad70..431350971ce590 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 12a3a263d2d6e2..7043c243d83454 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 fd63c972f1d196..911f9f79f3be0b 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 16e948f3a730f8..da62617d2861cd 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 aa79014959ab1e..180d1e5659f111 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 08a1317f6ac7e2..285b4c9056d763 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 b998300f065a34..3fbd23527579e2 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 323fc10fb018dc..e0a6bb1e0eba3e 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 2711362dd1874c..c31385f455371e 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 6a4370539afc1e..1fd7ed5b557f25 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 051/422] 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 90a440a6447af4..8799e032bfa6bc 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 8b4e76dd81f01b..de0f8bd2b46b6c 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 08a9274c3170ca..acc2a82e18ecb8 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 f22037c239af1b..40c15c7179410c 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 052/422] 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 9786aa84d1622f..eb6afcfd67cea5 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 e6ca25ecae075e..ab302a8f06d87f 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 60908d8a8d4f45..e654c6bf620817 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 053/422] 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 0bb66ab988c565..aecaea7fcbaf64 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 9f734939d73b5c..72d5606af43a13 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 054/422] 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 eb6afcfd67cea5..9786aa84d1622f 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 ab302a8f06d87f..e6ca25ecae075e 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 e654c6bf620817..60908d8a8d4f45 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 055/422] 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 60908d8a8d4f45..066e95fc655990 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 056/422] 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 7e07e7f0f3fddc..22ba7cee3d410c 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 8b2d0d45df7ee5..a11eca78e0b5d5 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 057/422] 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 de67375ccccf71..1cf70338f46b1a 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 2e5ef23875e6b8..f31ef08e1d2381 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 058/422] 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 9786aa84d1622f..126e75ae838e14 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 2ca96b5ac0e380..0c7db0d165fdb7 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 c0e74867cc62f7..38d6a169341385 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 69f93b740cd86e..26e38258f9496b 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 059/422] 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 0e6ab04228d43c..fea437d4ff9206 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 432bdc7004a4c6..00000000000000 --- 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 abda2118f84620..e72dec7e92aa46 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 20a81008d1c24a..00000000000000 --- 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 060/422] 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 bc57517ab75020..e2fcdbf54f861f 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 061/422] 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 329478819e491d..7b20620082db6d 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 62169c9a9389c6..3434674e630c58 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 2b2438c25e6d3c..80ba67d9941c30 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 745f511b12e177..250f56b3fd934d 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 6f12d78855106c..5229a83bb6e968 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 42c13f094c1893..a229d39ca8c72c 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 955f92dc19d012..d742dd06617197 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 062/422] 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 b0c33a3f505909..cf2782ad6317a8 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 197840ca3b8b43..3dc4722572b61e 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 063/422] 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 e2fcdbf54f861f..432d250584ed7b 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 88a9b6219db1b7..df3e9604f70c3b 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 49417be543557f..12d7aec9cc3692 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 50aff9d3ce764e..eb4e179171ce2e 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 da23116e83b465..62074471e14e84 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 5e4422a6fb7e6f..533913a654a6d9 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 064/422] 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 bf5b97607bbc3c..2bbda6647af961 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 99b6eadf2fddd7..5766d8b8fded30 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 b3b6c7aff816b0..9fbfd575dedf4d 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 065/422] 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 25e4ad9d999790..e1f31c3614cbb6 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 066/422] 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 432d250584ed7b..480b91504c3456 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 0473ff3932aa56..6eb69b83273fbf 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 4b6fc85041b346..f46146ef8c35ba 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 0ace54e100e5d3..445b991a132679 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 a05a538b4a7dc9..2da97d4bc568cc 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 067/422] 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 a2f299a3c9ed10..9ebeffda7a8177 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 ee673577c66881..7d07261f641c69 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 068/422] 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 445b991a132679..f7b36a9fc9de1b 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 069/422] 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 2237aeb1941a32..6667a3b5a1ffc9 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 e127da1f2f24b1..06da3cbd945b96 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 00000000000000..e41b09723dedaf --- /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 070/422] 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 e8f6115e02bb03..a26189de217f53 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 071/422] 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 | 372 ++++++++++++++++++++--------------- 1 file changed, 212 insertions(+), 160 deletions(-) diff --git a/crates/bedrock/src/models.rs b/crates/bedrock/src/models.rs index 9ebeffda7a8177..57c1eea01663c2 100644 --- a/crates/bedrock/src/models.rs +++ b/crates/bedrock/src/models.rs @@ -48,37 +48,27 @@ 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" - )] - ClaudeSonnet4, - #[default] #[serde( - rename = "claude-sonnet-4-5", - alias = "claude-sonnet-4-5-latest", - alias = "claude-sonnet-4-5-thinking", - alias = "claude-sonnet-4-5-thinking-latest" + rename = "claude-fable-5", + alias = "claude-fable-5-latest", + alias = "claude-fable-5-thinking", + alias = "claude-fable-5-thinking-latest" )] - ClaudeSonnet4_5, + ClaudeFable5, #[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-opus-4-8", + alias = "claude-opus-4-8-latest", + alias = "claude-opus-4-8-thinking", + alias = "claude-opus-4-8-thinking-latest" )] - ClaudeOpus4_1, + ClaudeOpus4_8, #[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" + 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_5, + ClaudeOpus4_7, #[serde( rename = "claude-opus-4-6", alias = "claude-opus-4-6-latest", @@ -87,19 +77,26 @@ pub enum Model { )] 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" + 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_7, + ClaudeOpus4_5, #[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" + 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_8, + 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", @@ -107,13 +104,23 @@ pub enum Model { alias = "claude-sonnet-4-6-thinking-latest" )] ClaudeSonnet4_6, + #[default] #[serde( - rename = "claude-fable-5", - alias = "claude-fable-5-latest", - alias = "claude-fable-5-thinking", - alias = "claude-fable-5-thinking-latest" + rename = "claude-sonnet-4-5", + alias = "claude-sonnet-4-5-latest", + alias = "claude-sonnet-4-5-thinking", + alias = "claude-sonnet-4-5-thinking-latest" )] - ClaudeFable5, + ClaudeSonnet4_5, + #[serde( + rename = "claude-sonnet-4", + alias = "claude-sonnet-4-latest", + alias = "claude-sonnet-4-thinking", + alias = "claude-sonnet-4-thinking-latest" + )] + 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::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::ClaudeFable5 => "claude-fable-5", + 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::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::ClaudeFable5 => "anthropic.claude-fable-5", + 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::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::ClaudeFable5 => "Claude Fable 5", + 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::ClaudeFable5 + | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeOpus4_6 + | Self::ClaudeSonnet5 => 128_000, + Self::ClaudeOpus4_5 + | Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_5 - | Self::ClaudeSonnet4_6 => 64_000, + | Self::ClaudeSonnet4 + | Self::ClaudeHaiku4_5 => 64_000, Self::ClaudeOpus4_1 => 32_000, - Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 - | Self::ClaudeOpus4_8 - | Self::ClaudeFable5 => 128_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 072/422] 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 7d07261f641c69..ba3b587151a060 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 073/422] 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 e51f29514a051b..9340ee6ac116b3 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 074/422] 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 b8a321d01b50a7..3761f19ec4ae6e 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 05a3451ea863a3..865f779b160599 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 05082637c276ff..6eb4a84a46cc90 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 bffd7e4c4d84a8..edb8a5e8da026d 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 bf374586b35fb5..aa47a73fc383e6 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 ef5077d53a2fa3..bbd53e1e733b3c 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 090baacf03224e..a91cdd2ea81a09 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 075/422] 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 f7b36a9fc9de1b..60afc057b7ab28 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 076/422] 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 fb2e4f6cd6b31c..1f2b2373819440 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() { + 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_no_aliases_for_build() {} + 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_build_target_specified() {} + 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 077/422] Update Wasmtime dependencies (#60341) Brings in the latest LTS security and bug fixes Release Notes: - N/A --- Cargo.lock | 311 +++++++++++++++++++++++++++-------------------------- Cargo.toml | 4 +- 2 files changed, 163 insertions(+), 152 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 480b91504c3456..5a64dbf6b4f2e6 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,58 +20431,58 @@ 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" +version = "0.227.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185" +checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2" dependencies = [ "bitflags 2.10.0", "hashbrown 0.15.5", - "indexmap 2.11.4", + "indexmap 2.14.0", "semver", - "serde", ] [[package]] name = "wasmparser" -version = "0.227.1" +version = "0.236.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2" +checksum = "a9b1e81f3eb254cf7404a82cee6926a4a3ccc5aad80cc3d43608a070c67aa1d7" dependencies = [ "bitflags 2.10.0", "hashbrown 0.15.5", - "indexmap 2.11.4", + "indexmap 2.14.0", "semver", + "serde", ] [[package]] name = "wasmparser" -version = "0.236.1" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9b1e81f3eb254cf7404a82cee6926a4a3ccc5aad80cc3d43608a070c67aa1d7" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.10.0", "hashbrown 0.15.5", - "indexmap 2.11.4", + "indexmap 2.14.0", "semver", - "serde", ] [[package]] name = "wasmparser" -version = "0.244.0" +version = "0.252.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c" dependencies = [ "bitflags 2.10.0", - "hashbrown 0.15.5", - "indexmap 2.11.4", + "hashbrown 0.17.1", + "indexmap 2.14.0", "semver", + "serde", ] [[package]] @@ -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 9f660975525e31..5e7703aaaeee3f 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 078/422] 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 23401ed93d0c64..a801da6131793a 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 079/422] 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 de4d538d40d3ba..889f89def325a3 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 291d7d5e7a8303..9df47fe2412054 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 080/422] 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 871da413e3bbee..9ba94563a4097e 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 20038e61234904..e3ec95859a240b 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 081/422] 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 ac637d3fc46876..256875bed1e4ec 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 cd0ed9241fa983..b74d28593d074d 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 ea888d564cc928..3b1e6db97fca52 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 082/422] 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 | 67 ++++++++++++++++++++------------- crates/project/src/git_store.rs | 5 ++- 2 files changed, 43 insertions(+), 29 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 60afc057b7ab28..6875ddea21886b 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)); - - 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, - }; + 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, + }; - 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 778743163d6516..fd3efdae5c8cf5 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 083/422] 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 00000000000000..e72e83a9abaf1e --- /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 5e7703aaaeee3f..2adf179e513d37 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 00000000000000..93dceb69922249 --- /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 00000000000000..26b452057c50a5 --- /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 00000000000000..d70050bfa6149a --- /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 00000000000000..6804a2e9093d24 --- /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 00000000000000..0ba8615542017c --- /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 00000000000000..e481cf91bc633c --- /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 00000000000000..d8d7fe16e917fe --- /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 00000000000000..fc17f3de2be9c6 --- /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 00000000000000..bf4289673dd439 --- /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 00000000000000..edc5fb26f4d230 --- /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 00000000000000..a47f2f434326a3 --- /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 00000000000000..f85adacc0ccbd4 --- /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 00000000000000..a07c992855ce89 --- /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 00000000000000..dd822953c2c5f9 --- /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 00000000000000..6b5d905664c33d --- /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 00000000000000..5d01d4268089b9 --- /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 00000000000000..7d863fbe83655e --- /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 00000000000000..4bb3411b825b11 --- /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 00000000000000..bce372c06eec52 --- /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 00000000000000..25a93028de26a4 --- /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 00000000000000..e578844dcdb7da --- /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 00000000000000..f95de0aa1f93f0 --- /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 00000000000000..e5f5197af5eb80 --- /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 00000000000000..f645bebbadca70 --- /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 00000000000000..fb502d22f02ac6 --- /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 00000000000000..d9e733902a1ae1 --- /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 00000000000000..53d9b7d9927fa6 --- /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 00000000000000..b4dd0aa9f54ef6 --- /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 00000000000000..ec6e13ea508ab1 --- /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 084/422] 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 126e75ae838e14..e4c837618d9cc6 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 0c7db0d165fdb7..622572abad3c07 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 085/422] 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 0f3c18d2245897..42a22219883b86 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 086/422] 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 57c1eea01663c2..ce5241a2dbb02a 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 087/422] 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 b5e6ab90ab9686..93e7b4d846b7c7 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 481f1433fbf7c8..eb08000d3a859e 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 748642b3cda6c1..5af92b2e451c0b 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 1949a5965a86e8..ccaf59f72b03f0 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 42a22219883b86..2d7c6b6ca40f16 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 97f45526cdcee2..6959b5bc4c06c8 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 088/422] 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 384474e21056ae..9366039969e512 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 089/422] 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 725db34126c3db..1dcac5480f5d1f 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 090/422] 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 50b62fa506bc31..326c741ad195cd 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 f09c13f54ab26c..2951a84b090c01 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 091/422] 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 22ba7cee3d410c..69fc69d5469115 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 092/422] 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 6875ddea21886b..134aad4bc69e62 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 093/422] 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 262dce5ac45241..93f48980a705e3 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 094/422] 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 75e52207f27f6e..381af5e2fa3027 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 fb2b277a634b5c..48b86de73b90f9 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 ccaf59f72b03f0..1640a90297f091 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 2d7c6b6ca40f16..c2f06da5261de2 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 ddc30b5d30ac86..28033e482f362b 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 a9ec2b028ff46d..ce968e3d611184 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 095/422] 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 913ab20c4c1f19..d2eb44ecc878de 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 7224fa15d4ce26..6bb94a892dc74a 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 0e6ad1cb0ea6f1..e96a1087bd5fa5 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 096/422] 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 9acc88da7556cf..884b0efd7794fd 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 4dcd72cbdf9b96..df4eecaccd5bd5 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 097/422] 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 dba403d4edec58..af19b77886ac26 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 e461aa2d7fa53d..4644549ea0b483 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 098/422] 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 c1e61822df6b23..62a9c75d518ecd 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 e97b6edff477af..9eb9d456a62eb1 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 099/422] 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 40c15c7179410c..5575046e25dc39 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 897a853662407c..a4fb6bfc60eae8 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 a4b17a0c09bc40..40fe15c08945ac 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 a11eca78e0b5d5..7eb78ca0ecc6f1 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 100/422] 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 c535acd80f0881..c7ed310b3bde04 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 4644549ea0b483..2c95d85a6ca34c 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 101/422] 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 500f8e4fe9e1ef..06679887155db1 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 102/422] 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 2f9d0ad3c4f48d..7915e6fd001f89 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 103/422] 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 971581dcf1abbd..dd1d4575fe8901 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 06679887155db1..86f4a79f75978f 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 4d4b07174793a8..63068b1a9edcd8 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 104/422] 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 | 165 +++++----- crates/git_ui/src/git_ui.rs | 161 +++++---- crates/git_ui/src/project_diff.rs | 226 +++++++------ 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, 693 insertions(+), 730 deletions(-) diff --git a/assets/icons/square_dot.svg b/assets/icons/square_dot.svg index 72b32734399a2d..a56ced308616c1 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 5ba458e8b53bf6..31b2c97d74b98f 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 063c7dbf8261d9..88ada58011f40e 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 ba731ec3b9ea23..9f51e53b292e58 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 c2f06da5261de2..90ea600ff5f17c 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 d2eb44ecc878de..1d224ad40b45b1 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 80f6bf7fab9f58..e5836446cee89a 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 9340ee6ac116b3..a6611b0c90ad56 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 e568b886f73af5..dd34391e195921 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 50090a559ca8fd..06c554e7b6e06e 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 134aad4bc69e62..e2b04699b224bb 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, + 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, - ); - }) - .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) + ) + } 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 a5dc98c5049cf0..08192d70c0fdae 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)) - }), + 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)) + }), + ) + }) + .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(), ) - }) - .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), - ) + .on_click(left_on_click) + .tooltip(move |window, cx| { + if let Some(operation) = in_progress_operation { + Tooltip::simple(in_progress_tooltip(operation), cx) } else { - this.child(Icon::new(left_icon).size(IconSize::XSmall)) + tooltip(window, cx) } - }) - }) - .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 89f57e274f805d..91f351bec29d63 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) + }, + ) + .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( - "Go to next hunk", - &GoToHunk, + "Stage All Changes", + &StageAll, &focus_handle, )) - .disabled(!button_states.prev_next) - .on_click(cx.listener(|this, _, window, cx| { - this.dispatch_action(&GoToHunk, window, cx) - })), - ), + .on_click( + cx.listener(|this, _, window, cx| this.stage_all(window, cx)), + ), + ) + }, ) - .child(vertical_divider()) + .child(Divider::vertical()) .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") - .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); + })), ) - // "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 90bde497fa1a84..9ada614cfcf02d 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 fc90c5f2530c57..ff0b11caa947be 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 5e20f2e7ecf6e3..d0c0a5385ff66a 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 01cfc9a38b56be..315bbc95f12c20 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 74c697a76ac1fb..7e343867f14231 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 105/422] 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 55736c0824b887..dc3dcd68b8d21a 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 106/422] 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 | 145 +++++++++++++----- .../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, 387 insertions(+), 135 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 9fb93ee27d5518..5a833447e9eda1 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 9918f6be0fc933..1c7b72da68e1a8 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 4f9724439f37b2..be39f41b6e54e5 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 3618d7230f79b4..7be909c907ccb1 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 f8fae3de7afc0a..07b17aca32a162 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 82dc9fb545d027..0e18eb2d16ded5 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 0d7ce3aad3ce9d..c6e79bff8488ff 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 be1d8e66d046ae..f309dd589b7d20 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 b8003a69b243c3..55ba214ad30568 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 90d1934ffcb6d5..4dbe5e8700a365 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 2cf27fea8b0652..a701371d76ea40 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 4866b3c33bc6ba..f6d04265975451 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 4e94c613a6b3dc..9bef39ade451fc 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 043c18ebc7c262..b40c55afb14ac7 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 6c492135ea6c3d..ace3c0729e7f2a 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 acd904841bb33c..12d10f10b26161 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 c3d04d5780b290..f0405af5e94424 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 e48ccdb082a362..df9fcc986937a0 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 6e68db7af0f274..494a97b0977469 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 23efa368d17653..9a6f223f38f22e 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 62a9c75d518ecd..d1a47870e6f486 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 fc1b49424dce24..9e86118dd0ab24 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 760cd9806c9928..c6e4bfe6e53b35 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 f658634c06c166..3ddd6bdd5c6e16 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 b2d8e96fcea1b5..bee3c7c21f51e5 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 8115259c0b78b1..ec65ab2c8725a3 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 e1c72767d77117..cf9f4728aa4595 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 b05015677c2132..4299bac4013a0b 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 3b613f255cc165..055279340ce4b3 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 ae58a67868d6b2..d02b3b23c232eb 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 3dd9ffeabae1b7..91507ebeefd804 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 e791cbc7ea4c37..f30650dc2204a8 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 1c3c0aec623c68..a35430857ecf8e 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 d14f4aa92451aa..1d073ba0484479 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 aa748dc578407f..f7e2287700fad6 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 dbe92a43a5a3c7..cf4aa1068fa7d7 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 d6be0299327ad2..2b57c8509cd87e 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) - } + let file_content = + WorkflowFile::load(&workflow_file_path).map_err(WorkflowError::ParseError)?; - 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 mut errors = Vec::new(); + + if let Err(error) = check_permissions::validate_permissions(&file_content.parsed_content) { + errors.push(ValidationError::Permissions(error)); } - let file_content = - WorkflowFile::load(&workflow_file_path).map_err(WorkflowError::ParseError)?; + errors.extend( + collect_run_injection_errors(&Value::Null, &file_content.parsed_content) + .into_iter() + .map(ValidationError::RunInjection), + ); - check_recursive(&Value::Null, &file_content.parsed_content).map_err(|errors| { - WorkflowError::ValidationError(Box::new(WorkflowValidationError::new( - errors, - file_content, - workflow_file_path, + 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 00000000000000..9fdbc003ddecc6 --- /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 50c435d033336d..2ea2aea517c777 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, -} - -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, +pub struct RunValidationError { + found_injection_patterns: Vec<(String, Range)>, } -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 14516c18f22a8d..86a2a952205dad 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 bb363736c345a2..960d5f24552df9 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 e576f43f91d438..60c664591181b2 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 fcff7418a2530c..6b9abab426cfa3 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 5501412f1be965..ccbbd19ee0f7c2 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 15d2808dbf6c8c..cc9e8ca64b4f29 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 5918bc476772ae..00f6f1fbe90428 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 c8e18f6966ac8d..564abf3b30b82f 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 e372a27d23729b..5302bc2f3ff214 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 f60d1097d6eda6..7871022f7702ee 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 e69c783299aa18..9958a7638d6ccd 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 e4efebfe67db95..9f6f6462b6c2b7 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 b184db64e3f412..8626dd5e1c0400 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 9eb9d456a62eb1..19e2c5e28dbfa5 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 4dc2560e2bea48..077a0e4cc20585 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 ae8000c15cad3a..3bcd87c433e6e3 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 cc04ec5b855ff4..2a4521ecdc41c0 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 cbe656e734eb73..d4843cbaf4a1a8 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 f04a9f7ca8ad6c..fc3060e375a5e3 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 a92188ee0d7baa..4c18ba213688ac 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 e9ed32c610ca1e..cb916e849eda43 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 dd422fa32e9267..c745d0e385223d 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 7351f451a94d86..5fcb0114421530 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 107/422] 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 055279340ce4b3..1b8f416baab558 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 00000000000000..1cd601d0a3affa --- /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 c745d0e385223d..84dc6692392b50 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 108/422] 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 e4c837618d9cc6..67643ee6345ee2 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 622572abad3c07..876c00b9ba6722 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 109/422] 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 25ac00a495c82b..b192fa3d683bb5 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 110/422] 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 858bf499cd5a72..1770bce23022af 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 0de0a5444d9da2..b02f5756cce385 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 95cb4626b3eb49..7f0fa5b7a8a30e 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 111/422] 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 a33a2e8cc1a779..2bc734cc10e1a2 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 64de772aec1a2b..c5889ae6663770 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 a2a196d1352180..dd8b866a6b1a1e 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 ca6379fa6eecf5..0f7896bc882400 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 c3cb556c7b1b4b..b7ae7d629ec9a4 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 a285e8aa70a72e..bcd1f5d8e62ef5 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 112/422] 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 1b8f416baab558..319c7290dd7768 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 84dc6692392b50..62043c545aa1e3 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 113/422] 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 00000000000000..6e2b1da8755027 --- /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 00000000000000..15824831a24d77 --- /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 00000000000000..15824831a24d77 --- /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 00000000000000..15824831a24d77 --- /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 00000000000000..15824831a24d77 --- /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 114/422] 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 | 646 ++-- crates/editor/src/split.rs | 188 +- 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 | 3028 +++++------------ 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} | 141 +- 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, 7749 insertions(+), 2983 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 9f51e53b292e58..079b043e467d70 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 e2ad7ca71ecb34..faa760b73a6ad9 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 c300ace11ae1d9..5cba96c5a92631 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 9b5df0b86713b2..bfda1aae44e4ec 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 1d224ad40b45b1..73a0c3ebfddd10 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 e5836446cee89a..a82b3ea4488a7b 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 6bb94a892dc74a..d9871fa2cb5898 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; - -pub type RenderDiffHunkControlsFn = Arc< - dyn Fn( - u32, - &DiffHunkStatus, - Range, - bool, - Pixels, - &Entity, - &mut Window, - &mut App, - ) -> AnyElement, ->; +use buffer_diff::{BufferDiff, DiffHunkStatus, DiffHunkStatusKind}; + +#[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 + } + + pub fn diff_hunk_delegate(&self) -> Arc { + self.diff_hunk_delegate + .clone() + .unwrap_or_else(|| Arc::new(UncommittedDiffHunkDelegate)) } - /// 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 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 dd34391e195921..93a6f96a443124 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; +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 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()); - } + 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 00000000000000..068d8e9af19734 --- /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 9fff74d3983897..a45590260dad2d 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 309a118078128a..1199afb9d4443c 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 00000000000000..96c05303719e2d --- /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 477f18be545dbc..b046380b28ae40 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 e2b04699b224bb..90463f1b9e1d29 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 08192d70c0fdae..aefd0eff50acf5 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 f8097e68f5c8d8..7bf7682938ca56 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 91f351bec29d63..d674be55766d94 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 the splittable editor of the currently-shown diff view. + pub fn editor(&self, cx: &App) -> Entity { + self.diff.read(cx).editor().clone() } - /// Returns a reference to the splittable editor. - pub fn editor(&self) -> &Entity { - &self.editor + /// 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,488 +351,62 @@ 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) - } + #[cfg(any(test, feature = "test-support"))] + pub fn excerpt_paths(&self, cx: &App) -> Vec> { + self.diff.read(cx).excerpt_paths(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, - }, - ); + #[cfg(any(test, feature = "test-support"))] + pub fn excerpt_file_paths(&self, cx: &App) -> Vec { + self.diff.read(cx).excerpt_file_paths(cx) + } +} - let snapshot = buffer.read(cx).snapshot(); - let diff_snapshot = diff.read(cx).snapshot(cx); +struct ButtonStates { + stage: bool, + unstage: bool, + prev_next: bool, + selection: bool, + stage_all: bool, + unstage_all: bool, +} - 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() - } - }; +impl EventEmitter for ProjectDiff {} - 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) - }); +impl Focusable for ProjectDiff { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.diff.read(cx).focus_handle(cx) + } +} - 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; - } - }) - }); +impl Item for ProjectDiff { + type Event = EditorEvent; - 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); - } + 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) + } - needs_fold.then_some(buffer_id) + fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { + self.diff + .update(cx, |diff, cx| diff.deactivated(window, cx)); } - fn buffer_ranges_changed( + fn navigate( &mut self, - path_key: PathKey, - file_status: FileStatus, - buffer: Entity, - diff: Entity, - conflict_set: Entity, + data: Arc, 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() - } - - /// 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 - } -} - -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()) -} - -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) - } - } -} - -impl Item for ProjectDiff { - 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.editor.update(cx, |editor, cx| { - editor.rhs_editor().update(cx, |primary_editor, cx| { - primary_editor.deactivated(window, cx); - }) - }); - } - - 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) - }) - }) + ) -> bool { + 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,8 +1006,69 @@ 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_save_after_restore(cx: &mut TestAppContext) { + async fn test_update_on_uncommit(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); @@ -2142,16 +1076,431 @@ mod tests { path!("/project"), json!({ ".git": {}, - "foo.txt": "FOO\n", + "README.md": "# My cool project\n".to_owned() }), ) .await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - - fs.set_head_for_repo( - path!("/project/.git").as_ref(), - &[("foo.txt", "foo\n".into())], - "deadbeef", + 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); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "foo.txt": "FOO\n", + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + fs.set_head_for_repo( + path!("/project/.git").as_ref(), + &[("foo.txt", "foo\n".into())], + "deadbeef", ); fs.set_index_for_repo( path!("/project/.git").as_ref(), @@ -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, @@ -2324,387 +1678,59 @@ mod tests { prev_buffer_hunks }); assert_eq!(prev_buffer_hunks.len(), 1); - cx.run_until_parked(); - - let new_buffer_hunks = - cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| { - let snapshot = buffer_editor.snapshot(window, cx); - let snapshot = &snapshot.buffer_snapshot(); - buffer_editor - .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], snapshot) - .collect::>() - }); - assert_eq!(new_buffer_hunks.as_slice(), &[]); - - cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| { - buffer_editor.set_text("different\n", window, cx); - buffer_editor.save( - SaveOptions { - format: false, - force_format: false, - autosave: false, - }, - project.clone(), - window, - cx, - ) - }) - .await - .unwrap(); - - cx.run_until_parked(); - - cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| { - buffer_editor.expand_all_diff_hunks(&Default::default(), window, cx); - }); - - assert_state_with_diff( - &buffer_editor, - cx, - &" - - original - + different - ˇ" - .unindent(), - ); - - assert_state_with_diff( - &diff_editor, - cx, - &" - - ˇoriginal - + different - " - .unindent(), - ); - } - - 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); - }); + cx.run_until_parked(); + + let new_buffer_hunks = + cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| { + let snapshot = buffer_editor.snapshot(window, cx); + let snapshot = &snapshot.buffer_snapshot(); + buffer_editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], snapshot) + .collect::>() }); - }); + assert_eq!(new_buffer_hunks.as_slice(), &[]); - 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()); + cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| { + buffer_editor.set_text("different\n", window, cx); + buffer_editor.save( + SaveOptions { + format: false, + force_format: false, + autosave: false, + }, + project.clone(), + window, + cx, + ) }) + .await .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_entity(&buffer_editor, |buffer_editor, window, cx| { + buffer_editor.expand_all_diff_hunks(&Default::default(), window, cx); }); - 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(); - }); + assert_state_with_diff( + &buffer_editor, + cx, + &" + - original + + different + ˇ" + .unindent(), + ); - cx.run_until_parked(); + assert_state_with_diff( + &diff_editor, + cx, + &" + - ˇoriginal + + different + " + .unindent(), + ); } #[gpui::test] @@ -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, @@ -2809,240 +1835,53 @@ mod tests { ); }); project - .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) - .await - .unwrap(); - cx.run_until_parked(); - - assert_state_with_diff( - &editor, - cx, - &" - one - - two - + TWO - three - four - five - ˇnine - ten - - eleven - + ELEVEN - twelve - " - .unindent(), - ); - } - - #[gpui::test] - async fn test_sort_by_name_tie_breaks_on_path(cx: &mut TestAppContext) { - init_test(cx); - - cx.update(|cx| { - cx.update_global::(|store, cx| { - store.update_user_settings(cx, |settings| { - let git_panel = settings.git_panel.get_or_insert_default(); - git_panel.sort_by = Some(GitPanelSortBy::Name); - git_panel.group_by = Some(GitPanelGroupBy::None); - }); - }); - }); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - ".git": {}, - "lib": { "foo.rs": "LIB FOO\n" }, - "src": { "foo.rs": "SRC FOO\n" }, - "m.rs": "M\n", - }), - ) - .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.new_window_entity(|window, cx| { - ProjectDiff::new(project.clone(), workspace, window, cx) - }); - cx.run_until_parked(); - - fs.set_head_and_index_for_repo( - path!("/project/.git").as_ref(), - &[ - ("lib/foo.rs", "lib foo\n".into()), - ("src/foo.rs", "src foo\n".into()), - ("m.rs", "m\n".into()), - ], - ); - cx.run_until_parked(); - - // Sorted by file name, the two `foo.rs` files come before `m.rs`, and the - // tie between them is broken by the full path (`lib/` before `src/`). - // A plain path sort would instead order them `lib/foo.rs`, `m.rs`, - // `src/foo.rs`. - let paths = diff.read_with(cx, |diff, cx| diff.excerpt_file_paths(cx)); - assert_eq!(paths, vec!["lib/foo.rs", "src/foo.rs", "m.rs"]); - } - - #[gpui::test] - async fn test_tree_view_orders_directories_before_files(cx: &mut TestAppContext) { - init_test(cx); - - cx.update(|cx| { - cx.update_global::(|store, cx| { - store.update_user_settings(cx, |settings| { - let git_panel = settings.git_panel.get_or_insert_default(); - git_panel.tree_view = Some(true); - git_panel.group_by = Some(GitPanelGroupBy::None); - }); - }); - }); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - ".git": {}, - "src": { - "a.rs": "A\n", - "m.rs": "M\n", - "sub": { "b.rs": "B\n" }, - }, - }), - ) - .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.new_window_entity(|window, cx| { - ProjectDiff::new(project.clone(), workspace, window, cx) - }); - cx.run_until_parked(); - - fs.set_head_and_index_for_repo( - path!("/project/.git").as_ref(), - &[ - ("src/a.rs", "a\n".into()), - ("src/m.rs", "m\n".into()), - ("src/sub/b.rs", "b\n".into()), - ], - ); - cx.run_until_parked(); - - // In tree view the `src/sub/` directory sorts before the files directly - // in `src/`. A plain path sort would interleave them as `src/a.rs`, - // `src/m.rs`, `src/sub/b.rs`. - let paths = diff.read_with(cx, |diff, cx| diff.excerpt_file_paths(cx)); - assert_eq!(paths, vec!["src/sub/b.rs", "src/a.rs", "src/m.rs"]); - } - - #[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| { - 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() - }); + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) + .await + .unwrap(); + cx.run_until_parked(); - 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 - })) - ) - ]) + assert_state_with_diff( + &editor, + cx, + &" + one + - two + + TWO + three + four + five + ˇnine + ten + - eleven + + ELEVEN + twelve + " + .unindent(), ); } #[gpui::test] - async fn test_branch_diff_action_matches_existing_item_by_base_ref(cx: &mut TestAppContext) { + async fn test_sort_by_name_tie_breaks_on_path(cx: &mut TestAppContext) { init_test(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + let git_panel = settings.git_panel.get_or_insert_default(); + git_panel.sort_by = Some(GitPanelSortBy::Name); + git_panel.group_by = Some(GitPanelGroupBy::None); + }); + }); + }); + let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/project"), json!({ ".git": {}, - "a.txt": "changed", + "lib": { "foo.rs": "LIB FOO\n" }, + "src": { "foo.rs": "SRC FOO\n" }, + "m.rs": "M\n", }), ) .await; @@ -3050,213 +1889,156 @@ mod tests { 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, - ); + let diff = cx.new_window_entity(|window, cx| { + ProjectDiff::new(project.clone(), workspace, window, cx) }); cx.run_until_parked(); - cx.focus(&workspace); - cx.update(|window, cx| { - window.dispatch_action(BranchDiff.boxed_clone(), cx); - }); + fs.set_head_and_index_for_repo( + path!("/project/.git").as_ref(), + &[ + ("lib/foo.rs", "lib foo\n".into()), + ("src/foo.rs", "src foo\n".into()), + ("m.rs", "m\n".into()), + ], + ); 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"]); + // Sorted by file name, the two `foo.rs` files come before `m.rs`, and the + // tie between them is broken by the full path (`lib/` before `src/`). + // A plain path sort would instead order them `lib/foo.rs`, `m.rs`, + // `src/foo.rs`. + let paths = diff.read_with(cx, |diff, cx| diff.excerpt_file_paths(cx)); + assert_eq!(paths, vec!["lib/foo.rs", "src/foo.rs", "m.rs"]); } #[gpui::test] - async fn test_update_on_uncommit(cx: &mut TestAppContext) { + async fn test_tree_view_orders_directories_before_files(cx: &mut TestAppContext) { init_test(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + let git_panel = settings.git_panel.get_or_insert_default(); + git_panel.tree_view = Some(true); + git_panel.group_by = Some(GitPanelGroupBy::None); + }); + }); + }); + let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/project"), json!({ ".git": {}, - "README.md": "# My cool project\n".to_owned() + "src": { + "a.rs": "A\n", + "m.rs": "M\n", + "sub": { "b.rs": "B\n" }, + }, }), ) .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 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()); - 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); + let diff = cx.new_window_entity(|window, cx| { + ProjectDiff::new(project.clone(), workspace, window, 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!("/project/.git").as_ref(), + &[ + ("src/a.rs", "a\n".into()), + ("src/m.rs", "m\n".into()), + ("src/sub/b.rs", "b\n".into()), + ], ); 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"); + // In tree view the `src/sub/` directory sorts before the files directly + // in `src/`. A plain path sort would interleave them as `src/a.rs`, + // `src/m.rs`, `src/sub/b.rs`. + let paths = diff.read_with(cx, |diff, cx| diff.excerpt_file_paths(cx)); + assert_eq!(paths, vec!["src/sub/b.rs", "src/a.rs", "src/m.rs"]); } #[gpui::test] - async fn test_deploy_at_respects_active_repository_selection(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_a"), - json!({ - ".git": {}, - "a.txt": "CHANGED_A\n", - }), - ) - .await; - fs.insert_tree( - path!("/project_b"), + path!("/a"), json!({ ".git": {}, - "b.txt": "CHANGED_B\n", + "main.rs": buffer_contents, }), ) .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())], + Path::new(path!("/a/.git")), + &[("main.rs", git_contents.to_owned())], ); - 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 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(); - // 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); + 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 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"); + let mut cx = EditorTestContext::for_editor_in(editor, cx).await; - // 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(); + cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}")); - 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()); + cx.dispatch_action(editor::actions::GoToHunk); + cx.dispatch_action(editor::actions::GoToHunk); + cx.dispatch_action(git::Restore); + cx.dispatch_action(editor::actions::MoveToBeginning); - 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 00000000000000..9b607c72cbc1b5 --- /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 5312ae6d08814d..991ebf2125243e 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 00000000000000..44f700c722a7ff --- /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 5b61e6237e7051..805ffc73550a60 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 fd3efdae5c8cf5..74efd3f0c8672e 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 67aa198945d239..2f1586d3e04d1b 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? + 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 = 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 { - project - .update(cx, |project, cx| { - project.open_uncommitted_diff(buffer.clone(), cx) - })? - .await? + None }; - 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)) + 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 7043c243d83454..6a08a291e31d88 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 da62617d2861cd..7b4f5839e946b7 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 c4c838042c0094..8d589f947cd90e 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 d0c0a5385ff66a..2c51c97a0eb03d 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 69fc69d5469115..2ecd422be6e6b9 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 7eb78ca0ecc6f1..c70e8d3273cd30 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 115/422] 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 5a64dbf6b4f2e6..689b4f65498da0 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 2adf179e513d37..15c7b4401a08bf 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 116/422] 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 d41143d12f5d89..c37065146f25bf 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 cdcd90314cd7a5..29afbe84b72c59 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 117/422] 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 181a4d447ba72f..2fa319300e5eab 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 e7ac11e773bade..666ba714f97399 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 118/422] 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 689b4f65498da0..b1d1e834170f83 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 c254989b4d5736..f4a59a3e08dc30 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 13e6e665244242..49c5d4bbe75786 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 119/422] 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 | 147 +++++--- .../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, 879 insertions(+), 172 deletions(-) diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index 9366039969e512..b360e1ec578395 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, - ) - } 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, + .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(); + }), ) - }, - 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 4b98199239b20a..2a3388a406ae5a 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 609ddef1d81e4b..805c93708ac871 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 scrollable_items: Vec = items_vec.drain(pinned_cols..).collect(); - let scrollable_widths: Vec> = widths_vec.drain(pinned_cols..).collect(); + let items_vec: Vec = items.map(IntoElement::into_any_element).into_vec(); + let widths_vec: Vec> = column_widths.into_vec(); + + // 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 scrollable_headers: Vec = headers_vec.drain(pinned_cols..).collect(); - let scrollable_widths: Vec> = widths_vec.drain(pinned_cols..).collect(); + let widths_vec: Vec> = column_widths.into_vec(); + + // 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 9ef75e4cbbb727..94d9910df3366b 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 509cceca71204b..652ef47467fe13 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 cb5da35d565185..0fa93c8ba35d82 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 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 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 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 120/422] 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 5765fd80aff0b5..13fcbcee7a7528 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 198fab268e8cfb..fcf10ac5576dfe 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 da759cfebc733f..731569a025fa7a 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 121/422] 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 8a42c8bd492469..b816f7dd935afd 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 122/422] 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 2808e535ad55e5..fbc48864ce5216 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 123/422] 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 619765a2fd6abe..b293763bd9ede4 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 c9352a60035ace..547ee7a9dde9b0 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 124/422] 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 938633d4749118..d3d87608253e03 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 125/422] 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 dc3dcd68b8d21a..47ae6942010aef 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 126/422] 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 3f756a876a862a..01550cd16eae56 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 127/422] 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 | 162 +++++++++++++++--- crates/settings_content/src/language_model.rs | 16 +- docs/src/ai/use-api-access.md | 4 +- 4 files changed, 180 insertions(+), 38 deletions(-) diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index 94689cc0d66b84..c3e5c0f8dab4bb 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 ab030721e35ab7..79d95b9cd25a62 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,27 +677,119 @@ impl Model { pub fn supported_reasoning_effort_levels(&self) -> Option> { match self { - Self::ClaudeOpus4_8 => Some(vec![ + // Anthropic models + Self::ClaudeFable5 + | Self::ClaudeOpus4_8 + | Self::ClaudeOpus4_7 + | Self::ClaudeSonnet5 => Some(vec![ ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High, ReasoningEffort::XHigh, + ReasoningEffort::Max, ]), - Self::Nemotron3UltraFree | Self::MimoV2_5Pro | Self::MimoV2_5 => Some(vec![ + Self::ClaudeOpus4_6 | Self::ClaudeSonnet4_6 => Some(vec![ ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High, + ReasoningEffort::Max, ]), - Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => Some(vec![ + 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 e58f3dcc02b511..f61d80ead75efb 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 0ddf85ba33b77c..7af0c0e68a3948 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 128/422] 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 67643ee6345ee2..4193e8185cda43 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 129/422] 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 b1d1e834170f83..1ada6ef9e5ce01 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 14b9fe436791eb..ba5935c65edf62 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 4eb73b6d82e759..5741d61348538f 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 130/422] 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 4a61069a0a9b71..ebbce8f84c9d7d 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 bf3b33efc725c2..4dfa07b6b2c482 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 131/422] 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 fcf10ac5576dfe..f4bd64ecba51b0 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 147630d054d180..d2edb72408bed9 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 132/422] 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 5abd3341d22b07..35124b15ef8b4b 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 133/422] 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 1ada6ef9e5ce01..c0711dee737037 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 15c7b4401a08bf..3611fce865bde9 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 ae3f3a81b85517..3eaaa0652bd8ab 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 134/422] 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 666ba714f97399..96f02fcb778f79 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 76f2398b382cf1..04109ce1b2f19e 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 135/422] 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 b360e1ec578395..90efa57d228609 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 90463f1b9e1d29..b1667adff5972e 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 d674be55766d94..445eaf1e7040cd 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 a8786aaa40c812..7c3845eb98f67b 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 4dfa07b6b2c482..8b830513b2c68c 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 805c93708ac871..8d785fec8a0d1c 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 9e78b0d4bf68b8..f83dda592f6145 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 136/422] 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 f1b3ec285159b1..e4fd17159ea7e7 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 137/422] 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 b1667adff5972e..590efaca2b74ff 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 138/422] 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 | 55 ++++++--- 3 files changed, 222 insertions(+), 85 deletions(-) diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 45ee57b35f5ce6..ba23efb166b56a 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 2951a84b090c01..6c88ee4e688336 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 74efd3f0c8672e..6bf3fb85005905 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 revisions = Vec::new(); + for (_, repo_path, is_symlink, current_index_text, current_head_text) in + &repo_diff_state_updates + { + 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, - repo_path, - is_symlink, - current_index_text, - current_head_text, - ) in &repo_diff_state_updates + for (buffer, _, 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; + 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 139/422] 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 590efaca2b74ff..d20065cc6ca312 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 29a06747f9e4bb..bb1f3ee7aa1c1a 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 c70e8d3273cd30..12b34def15c87f 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 140/422] 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 4c4a39122630e9..ec75be0180e9e2 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 4fc663809547de..016d4ca20f3ea4 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 141/422] 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 08f30dc0090d3a..587315debfa89b 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 142/422] 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 d20065cc6ca312..6c0375fcbfa179 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 157ce8316775d9..3c9921c0f96644 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 143/422] 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 59b00f936f922b..aae9dbb942d8ad 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 3c28e07e6b306e..44092bf2639827 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 96536b6c021c6c..ffddd3d90f89ee 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 57f910b5ea1654..db9ff6524c2b06 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 144/422] 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 | 358 +++++-- 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, 1251 insertions(+), 138 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c0711dee737037..46c2fc46e2f112 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 3611fce865bde9..7a7003ec5eb1ce 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 ce5241a2dbb02a..cd035be819874e 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!(!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::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!(!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 57a0d530e451b2..8da9eb332ed79c 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 ba3b587151a060..fe12a5eb3a12db 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 1ce7caa3a675fb..4f360eea95c845 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 f39a9af218fd42..0638203d7695c7 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 647f9fcbacec78..33ec1dfba4676a 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 f61d80ead75efb..5abc65dc805c0d 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 e010cfe8159aa2..6cd1a7a614df1c 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 145/422] 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 | 97 +++++++++---------- 1 file changed, 46 insertions(+), 51 deletions(-) diff --git a/crates/cloud_api_client/src/cloud_api_client.rs b/crates/cloud_api_client/src/cloud_api_client.rs index e2f9d69002e236..aab5f3b60da731 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)?), - )?; - - self.send_authenticated_request(request).await?; + 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, 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)?), - )?; - - self.send_authenticated_request(request).await?; + 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, 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)?), - )?; - - self.send_authenticated_request(request).await?; + 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, 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 146/422] 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 46c2fc46e2f112..1d5fe8fd9bd8b7 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 1ca500832e2807..b6df4a370fc87a 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 96aeae19933de9..ff4b8c4b8d055e 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 147/422] 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 161f65974c4980..dd88d76741376a 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 148/422] 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 96f02fcb778f79..2a95034cbaa7dc 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 149/422] 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 876bb8fa286535..8192fc4a18f56b 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 f3711361d13430..1203e3dde021b7 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 25e869625fb5ed..00b73f1e44e060 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 7c3845eb98f67b..c880891502b8b7 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 540bc2db11ac36..0cecb38decf62d 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 150/422] 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 7d69e03c325a93..a09aa9c2c132fd 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 aecaea7fcbaf64..db40513599475e 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 25946c0b7c03c1..f897bdd1d96760 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 21ebdcbd049f9a..9b8e77639444d4 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 151/422] 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 2a95034cbaa7dc..1555a25dd21d38 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 7b4f5839e946b7..47ff6c0740987f 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 152/422] 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 da42904fab9426..29b9bd7554ac84 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 13a4fcc9bdf787..87efea16c854d2 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 153/422] 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 d297b5fa98f513..0834d7eabbab84 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 5a2423cae65aad..75c21d68b751cc 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 0dc4c1aaf29755..e9d36860852b7d 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 43e9b2ff375a37..5d110eb3ce12dc 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 73a0c3ebfddd10..96567329721a9b 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 a6611b0c90ad56..0ba76a137617fe 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 f61546e289a7db..4e97c8c3fe3552 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 b046380b28ae40..a8159566da4d90 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 7bf7682938ca56..47bf5a2b727a44 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 991ebf2125243e..c9cf6b5f537915 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 805ffc73550a60..e233f393b2949c 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 154/422] 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 297e3c32c71ba6..00e94b0b07f61c 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 777fd0e9b085be..89d7beac865006 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 aef97e37f5ade2..70f0ff4f93835d 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 8d0b0b7c0dce7a..2565afc2ddfbe3 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 cff57ca2c0655e..789e463d9c31da 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 5113453a7ba8d1..835fcd789d4d8b 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 b503ff09849fc2..4c7c21e5e386e3 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 4e14aceba40005..4b57a30d5bafad 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 5c019ecac36dd9..7af28e062c5c7f 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 bc39c00fdd0b64..538134694ac1f5 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 57b98234406662..08d95119077c6f 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 d341684f89cb78..13dfa2a4aaf772 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 68514d44092d0c..07f2a00556e867 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 fd595bc7e3391a..cf85b68fbe2d1f 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 44025da5544315..e8e8acf9f619e4 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 cc9f498a3d5b65..8a290a0d7a72cc 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 a789506e1c537a..a9bc465e2e0aed 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 47511ec713d2d3..16e5c2ad60406f 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 41644a8b0556c3..dcede9924071ec 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 27e5daf09c5709..79cf7d65795e50 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 0dd931f5140b7d..4ca22cd9f83624 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 8568ca27ffd2d3..bc6a97a6d96120 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 c4c454118ec2e2..d67c65c248b1c0 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 410e8f153f029b..bfc61e90455bee 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 c97dd23d1956a7..5ecb906289ebe4 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 c1a190d89053f0..afee88bbf36864 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 ebeacf2bb96fb0..1ca24ef7616ea8 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 9c9e8152fe6b96..d23f9520aa7177 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 b23bb27094fcc2..255e1a40add463 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 b8447b83e56f42..db8bc03d40eef4 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 4d5a18d7b201c4..4c190143c5e6f1 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 155/422] 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 cdf503fa23c95d..754ca7d1ba92ec 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 adc2063341af73..d7cabce3c2a86f 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 156/422] 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 8725587ade6d68..983784e89517e8 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 f7ad53118d3a61..9b8584c4e6ba15 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 c7c1013278fe92..e66795a193f427 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 94dff0038f0c78..ba35c4875ef8dc 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 9fbfd575dedf4d..796593fe45d951 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 8a31745d67d374..64d76b7de3fccb 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 1ca24ef7616ea8..1450b58d6fc002 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 e4e876ab2db480..f1e82ca0955727 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 157/422] 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 8b830513b2c68c..8b23423a973a1b 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 afee88bbf36864..030177af8f66f4 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 158/422] 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 2bbda6647af961..9ec28e47d73281 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 159/422] 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 96567329721a9b..87589880b53416 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 79c65b227bb942..2ab20aef36e4a7 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 c426127dc075d7..4a874591f72b3f 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 160/422] 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 e6ca25ecae075e..0467c3e4fd8562 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 161/422] 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 65632e9406b03e..110961b34f1a48 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 90ea600ff5f17c..fe8b7b29233acd 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 162/422] 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 fa6fcace40f376..bad1feabb3b3ef 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 00000000000000..2064d7cacef29c --- /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 00000000000000..25d74013deb757 --- /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 00000000000000..a3edae8cda1f6d --- /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 00000000000000..07640b93294677 --- /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 00000000000000..0eac8494ffc016 --- /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 f212f246caab86..dbd82b1c2ef936 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 39b87dbb8039e7..394f00c1082db4 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())) - } - - fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { - None +/// `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 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 f4bd64ecba51b0..62a8011d4e6536 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 89d609ae65d604..51d2a8ab3f7214 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 f3958bca568c94..50305af752c393 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 dd88d76741376a..0fc345b1b7c9f3 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 163/422] 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 322777da042c2b..8f8ba76875436b 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 efa3573d7aa53d..116c8912a38684 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 3d0cf50cf1d34f..d9e7ce6ad9b61b 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 90500d53d06917..335633656afb84 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 06a26e4696e471..e24441d3885e9e 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 cafa2a4c1bd954..18c185bb0f3c66 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 05652b49a48ca9..1ce8d34d22b5ac 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 215d681c28fd7f..2d5cc78c5f5645 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 382b41a2850721..f2613215fa5c04 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 00000000000000..b2b8a78c05aea8 --- /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 85b38d5234efec..62b481bc53045d 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 164/422] 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 1d5fe8fd9bd8b7..b9b4ea96158f45 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 2b67736bf0e250..213147207c3c6f 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 165/422] 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 c31385f455371e..6c90de189b5f82 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 1fd7ed5b557f25..f63bf7d1085f2d 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 166/422] 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 2fa319300e5eab..ca5cc9a6a2fda3 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 a82b3ea4488a7b..4ce36632f254a8 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 ec7f9036c4a2d4..dcd96c675c50e9 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 167/422] 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 babab1c8b9ccb7..8aad061e5f8a7c 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 f295dcb4f9a68f..524d301aecc865 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 1fe4c34baaf715..7bdd8ab3a2a7ba 100644 --- a/script/github-guild-board.py +++ b/script/github-guild-board.py @@ -38,7 +38,10 @@ 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 168/422] 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 983784e89517e8..0e42d27e980342 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 ca5cc9a6a2fda3..9b78a9a5c0cdf4 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 4e97c8c3fe3552..4acdb10ff7560a 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 8942008e63219b..bb2a5bbb299fa8 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 1555a25dd21d38..f9e563ccdb39a2 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 29d9f91dc6b56a..ae63dbe75fcf5b 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 9b8584c4e6ba15..0d2f5511430a48 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 f31ef08e1d2381..71beec78f7d6c4 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 ba35c4875ef8dc..2f6ce0beec5b3d 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 1450b58d6fc002..2ded300ce5dc9e 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 169/422] 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 b9b4ea96158f45..3f71a7000d7f3b 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 7a7003ec5eb1ce..916cb147fa5554 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 c2804f853a359b..ccb38746a2be60 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 79266405084d29..98cf7dc90abc3d 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 170/422] 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 bad1feabb3b3ef..eab072e470b3ce 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 13fcbcee7a7528..2a37d0b19b2e2c 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 00000000000000..a1f8d7ea0d572f --- /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 3e90688d1bd98b..cbc962bcffe742 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 256875bed1e4ec..9522ebb8f0d298 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 00000000000000..4a15e78211b703 --- /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 731569a025fa7a..675602082bb945 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 d2edb72408bed9..a459ed03b5d157 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 8266cade4d226b..eed9e90bcd3f7b 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 d3d87608253e03..6221b14161096c 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 fecc39f368dc8c..c39723ea5b2e15 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 547ee7a9dde9b0..76075a66b1cb19 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 171/422] 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 76075a66b1cb19..5a4129b493c1f7 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 172/422] 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 ebbce8f84c9d7d..04692c86a55e2a 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 9522ebb8f0d298..a4cc9fde9f1d5e 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 173/422] 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 3f71a7000d7f3b..f65225de1bd947 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 a1e0531f3d5185..262da8d4dffb8d 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 174/422] 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 08b0265fafbbb0..99fca0c7116614 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 87e5110ff52b6e..a422d2811dbb6d 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 00000000000000..4f3f919582737f --- /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 df3b556d6a47ca..4b0c2ca04bcb90 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 00000000000000..43438207d8c600
--- /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 2eed5f7907a668..e1a48d1f7f05f1 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 8f5b40cc19ecfd..f0169fd7c87f73 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 9c8077bad525da..d32edaa8d61ad7 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 2c7786817aa2f1..d5e0b620051536 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 }}} + + +
+
+ +
+ + +
+
+