From 95ae388c0ce8ae4a0b8d149ace3ecba1ea417491 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Fri, 19 Dec 2025 09:19:04 -0300 Subject: [PATCH 01/19] Fix title bar spacing when building on the macOS Tahoe SDK (#45351) The size and spacing around the traffic light buttons changes after macOS SDK 26. Our official builds aren't using this SDK yet, but dev builds sometimes are and the official will in the future.
Before After
CleanShot 2025-12-19 at 08 58 53@2x CleanShot 2025-12-19 at 08 57 02@2x
CleanShot 2025-12-19 at 08 59 40@2x CleanShot 2025-12-19 at 09 01 17@2x
Release Notes: - N/A --- crates/title_bar/build.rs | 28 +++++++++ .../title_bar/src/platforms/platform_mac.rs | 14 +++-- crates/title_bar/src/title_bar.rs | 58 ++++++++++--------- 3 files changed, 68 insertions(+), 32 deletions(-) create mode 100644 crates/title_bar/build.rs diff --git a/crates/title_bar/build.rs b/crates/title_bar/build.rs new file mode 100644 index 0000000000000000000000000000000000000000..ef70268ad3127baf113824348cb3e8685392a52b --- /dev/null +++ b/crates/title_bar/build.rs @@ -0,0 +1,28 @@ +#![allow(clippy::disallowed_methods, reason = "build scripts are exempt")] + +fn main() { + println!("cargo::rustc-check-cfg=cfg(macos_sdk_26)"); + + #[cfg(target_os = "macos")] + { + use std::process::Command; + + let output = Command::new("xcrun") + .args(["--sdk", "macosx", "--show-sdk-version"]) + .output() + .unwrap(); + + let sdk_version = String::from_utf8(output.stdout).unwrap(); + let major_version: Option = sdk_version + .trim() + .split('.') + .next() + .and_then(|v| v.parse().ok()); + + if let Some(major) = major_version + && major >= 26 + { + println!("cargo:rustc-cfg=macos_sdk_26"); + } + } +} diff --git a/crates/title_bar/src/platforms/platform_mac.rs b/crates/title_bar/src/platforms/platform_mac.rs index c7becde6c1af48bf37e06c0d2dcf991ad3c9f19f..5e8e4e5087054e59f66527915ae97e352a9ff525 100644 --- a/crates/title_bar/src/platforms/platform_mac.rs +++ b/crates/title_bar/src/platforms/platform_mac.rs @@ -1,6 +1,10 @@ -/// Use pixels here instead of a rem-based size because the macOS traffic -/// lights are a static size, and don't scale with the rest of the UI. -/// -/// Magic number: There is one extra pixel of padding on the left side due to -/// the 1px border around the window on macOS apps. +// Use pixels here instead of a rem-based size because the macOS traffic +// lights are a static size, and don't scale with the rest of the UI. +// +// Magic number: There is one extra pixel of padding on the left side due to +// the 1px border around the window on macOS apps. +#[cfg(macos_sdk_26)] +pub const TRAFFIC_LIGHT_PADDING: f32 = 78.; + +#[cfg(not(macos_sdk_26))] pub const TRAFFIC_LIGHT_PADDING: f32 = 71.; diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 23572677919509d859a141cb09cce8f5822697ef..d7759b0df8019eed2ad59b73bcaffaa3ffcfb866 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -447,34 +447,38 @@ impl TitleBar { return None; } - Some( - Button::new("restricted_mode_trigger", "Restricted Mode") - .style(ButtonStyle::Tinted(TintColor::Warning)) - .label_size(LabelSize::Small) - .color(Color::Warning) - .icon(IconName::Warning) - .icon_color(Color::Warning) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .tooltip(|_, cx| { - Tooltip::with_meta( - "You're in Restricted Mode", - Some(&ToggleWorktreeSecurity), - "Mark this project as trusted and unlock all features", - cx, - ) - }) - .on_click({ - cx.listener(move |this, _, window, cx| { - this.workspace - .update(cx, |workspace, cx| { - workspace.show_worktree_trust_security_modal(true, window, cx) - }) - .log_err(); - }) + let button = Button::new("restricted_mode_trigger", "Restricted Mode") + .style(ButtonStyle::Tinted(TintColor::Warning)) + .label_size(LabelSize::Small) + .color(Color::Warning) + .icon(IconName::Warning) + .icon_color(Color::Warning) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .tooltip(|_, cx| { + Tooltip::with_meta( + "You're in Restricted Mode", + Some(&ToggleWorktreeSecurity), + "Mark this project as trusted and unlock all features", + cx, + ) + }) + .on_click({ + cx.listener(move |this, _, window, cx| { + this.workspace + .update(cx, |workspace, cx| { + workspace.show_worktree_trust_security_modal(true, window, cx) + }) + .log_err(); }) - .into_any_element(), - ) + }); + + if cfg!(macos_sdk_26) { + // Make up for Tahoe's traffic light buttons having less spacing around them + Some(div().child(button).ml_0p5().into_any_element()) + } else { + Some(button.into_any_element()) + } } pub fn render_project_host(&self, cx: &mut Context) -> Option { From b9aef75f2df4ae8fa1707d348b4d588c3784526b Mon Sep 17 00:00:00 2001 From: Lena <241371603+zelenenka@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:41:03 +0100 Subject: [PATCH 02/19] Turn on the fixed stalebot (#45355) It will run weekly and it promised not to touch issues of the wrong types anymore. Release Notes: - N/A --- .github/workflows/community_close_stale_issues.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/community_close_stale_issues.yml b/.github/workflows/community_close_stale_issues.yml index 113e5ed131d1443c5481ff2966fac6a234561a20..6347b713257f49c02f981774faa0d0359e05e4d3 100644 --- a/.github/workflows/community_close_stale_issues.yml +++ b/.github/workflows/community_close_stale_issues.yml @@ -1,7 +1,7 @@ name: "Close Stale Issues" on: schedule: - - cron: "0 8 31 DEC *" + - cron: "0 2 * * 5" workflow_dispatch: inputs: debug-only: From 1dc5de4592ebf0ec51ba77bbd41c495aee67184e Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Fri, 19 Dec 2025 13:54:30 +0100 Subject: [PATCH 03/19] workspace: Auto-switch git context when focus changed (#45354) Closes #44955 Release Notes: - Fixed workspace incorrectly automatically switching Git repository/branch context in multi-repository projects when repo/branch switched manually from the Git panel. --- crates/workspace/src/workspace.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 0c5c9ffa5d0bfb1f70ce6a861b0209f321222fc0..139fa88359c574e1565ed778fdfbd5fa1c8f7944 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -4223,7 +4223,7 @@ impl Workspace { cx: &mut Context, ) { self.active_pane = pane.clone(); - self.active_item_path_changed(window, cx); + self.active_item_path_changed(true, window, cx); self.last_active_center_pane = Some(pane.downgrade()); } @@ -4280,7 +4280,7 @@ impl Workspace { } serialize_workspace = *focus_changed || pane != self.active_pane(); if pane == self.active_pane() { - self.active_item_path_changed(window, cx); + self.active_item_path_changed(*focus_changed, window, cx); self.update_active_view_for_followers(window, cx); } else if *local { self.set_active_pane(pane, window, cx); @@ -4296,7 +4296,7 @@ impl Workspace { } pane::Event::ChangeItemTitle => { if *pane == self.active_pane { - self.active_item_path_changed(window, cx); + self.active_item_path_changed(false, window, cx); } serialize_workspace = false; } @@ -4465,7 +4465,7 @@ impl Workspace { cx.notify(); } else { - self.active_item_path_changed(window, cx); + self.active_item_path_changed(true, window, cx); } cx.emit(Event::PaneRemoved); } @@ -4719,14 +4719,19 @@ impl Workspace { self.follower_states.contains_key(&id.into()) } - fn active_item_path_changed(&mut self, window: &mut Window, cx: &mut Context) { + fn active_item_path_changed( + &mut self, + focus_changed: bool, + window: &mut Window, + cx: &mut Context, + ) { cx.emit(Event::ActiveItemChanged); let active_entry = self.active_project_path(cx); self.project.update(cx, |project, cx| { project.set_active_path(active_entry.clone(), cx) }); - if let Some(project_path) = &active_entry { + if focus_changed && let Some(project_path) = &active_entry { let git_store_entity = self.project.read(cx).git_store().clone(); git_store_entity.update(cx, |git_store, cx| { git_store.set_active_repo_for_path(project_path, cx); From 69f6eeaa3ae821c43b7bf0dfc241c2792573b338 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:06:15 +0100 Subject: [PATCH 04/19] toolchains: Fix persistence by not relying on unstable worktree id (#45357) Closes #42268 We've migrated user selections when a given workspace has a single worktree (as then we could determine what the target worktree is). Release Notes: - python: Fixed selected virtual environments not being persisted/deserialized correctly within long-running Zed sessions (where multiple different projects might've been opened). This is a breaking change for users of multi-worktree projects - your selected toolchain for those projects will be reset. Co-authored-by: Dino --- crates/language/src/toolchain.rs | 7 +- crates/project/src/project.rs | 7 +- crates/project/src/toolchain_store.rs | 26 +++++- crates/project/src/x.py | 1 + .../src/active_toolchain.rs | 9 ++- .../src/toolchain_selector.rs | 43 +++++++--- crates/workspace/src/persistence.rs | 81 ++++++++++++++----- crates/workspace/src/workspace.rs | 31 ++++++- 8 files changed, 165 insertions(+), 40 deletions(-) create mode 100644 crates/project/src/x.py diff --git a/crates/language/src/toolchain.rs b/crates/language/src/toolchain.rs index 5717ffb5143e38bce736c354b43febc86e321f32..815ece30a1ed46ae65ec4af2ba64501ff3489718 100644 --- a/crates/language/src/toolchain.rs +++ b/crates/language/src/toolchain.rs @@ -4,7 +4,10 @@ //! which is a set of tools used to interact with the projects written in said language. //! For example, a Python project can have an associated virtual environment; a Rust project can have a toolchain override. -use std::{path::PathBuf, sync::Arc}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; use async_trait::async_trait; use collections::HashMap; @@ -36,7 +39,7 @@ pub struct Toolchain { /// - Only in the subproject they're currently in. #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] pub enum ToolchainScope { - Subproject(WorktreeId, Arc), + Subproject(Arc, Arc), Project, /// Available in all projects on this box. It wouldn't make sense to show suggestions across machines. Global, diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 5e31f2a90cf137f1e4d788952832e1eb2ee0ec35..25a19788fdb464f5f289ef3bc3513f21743e3a9a 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -1330,7 +1330,12 @@ impl Project { cx.subscribe(&buffer_store, Self::on_buffer_store_event) .detach(); let toolchain_store = cx.new(|cx| { - ToolchainStore::remote(REMOTE_SERVER_PROJECT_ID, remote.read(cx).proto_client(), cx) + ToolchainStore::remote( + REMOTE_SERVER_PROJECT_ID, + worktree_store.clone(), + remote.read(cx).proto_client(), + cx, + ) }); let task_store = cx.new(|cx| { TaskStore::remote( diff --git a/crates/project/src/toolchain_store.rs b/crates/project/src/toolchain_store.rs index 21b74bd784d1d9af12fe43e3fe82051afc103b0d..7afc70827f85e1a1bafcad436409936876fd3b45 100644 --- a/crates/project/src/toolchain_store.rs +++ b/crates/project/src/toolchain_store.rs @@ -32,6 +32,7 @@ use crate::{ pub struct ToolchainStore { mode: ToolchainStoreInner, user_toolchains: BTreeMap>, + worktree_store: Entity, _sub: Subscription, } @@ -66,7 +67,7 @@ impl ToolchainStore { ) -> Self { let entity = cx.new(|_| LocalToolchainStore { languages, - worktree_store, + worktree_store: worktree_store.clone(), project_environment, active_toolchains: Default::default(), manifest_tree, @@ -77,12 +78,18 @@ impl ToolchainStore { }); Self { mode: ToolchainStoreInner::Local(entity), + worktree_store, user_toolchains: Default::default(), _sub, } } - pub(super) fn remote(project_id: u64, client: AnyProtoClient, cx: &mut Context) -> Self { + pub(super) fn remote( + project_id: u64, + worktree_store: Entity, + client: AnyProtoClient, + cx: &mut Context, + ) -> Self { let entity = cx.new(|_| RemoteToolchainStore { client, project_id }); let _sub = cx.subscribe(&entity, |_, _, e: &ToolchainStoreEvent, cx| { cx.emit(e.clone()) @@ -90,6 +97,7 @@ impl ToolchainStore { Self { mode: ToolchainStoreInner::Remote(entity), user_toolchains: Default::default(), + worktree_store, _sub, } } @@ -165,12 +173,22 @@ impl ToolchainStore { language_name: LanguageName, cx: &mut Context, ) -> Task> { + let Some(worktree) = self + .worktree_store + .read(cx) + .worktree_for_id(path.worktree_id, cx) + else { + return Task::ready(None); + }; + let target_root_path = worktree.read_with(cx, |this, _| this.abs_path()); + let user_toolchains = self .user_toolchains .iter() .filter(|(scope, _)| { - if let ToolchainScope::Subproject(worktree_id, relative_path) = scope { - path.worktree_id == *worktree_id && relative_path.starts_with(&path.path) + if let ToolchainScope::Subproject(subproject_root_path, relative_path) = scope { + target_root_path == *subproject_root_path + && relative_path.starts_with(&path.path) } else { true } diff --git a/crates/project/src/x.py b/crates/project/src/x.py new file mode 100644 index 0000000000000000000000000000000000000000..58947a58a41bcc2e2f8c2046b5e1c8c38c0fbbb8 --- /dev/null +++ b/crates/project/src/x.py @@ -0,0 +1 @@ +Gliwice makerspace \ No newline at end of file diff --git a/crates/toolchain_selector/src/active_toolchain.rs b/crates/toolchain_selector/src/active_toolchain.rs index 03c152e3fd3df0c62ab2f5c7e4a4746875ac955a..06f7d1cdf3e27f43bdb5013038b943b9e5193680 100644 --- a/crates/toolchain_selector/src/active_toolchain.rs +++ b/crates/toolchain_selector/src/active_toolchain.rs @@ -198,10 +198,17 @@ impl ActiveToolchain { .or_else(|| toolchains.toolchains.first()) .cloned(); if let Some(toolchain) = &default_choice { + let worktree_root_path = project + .read_with(cx, |this, cx| { + this.worktree_for_id(worktree_id, cx) + .map(|worktree| worktree.read(cx).abs_path()) + }) + .ok() + .flatten()?; workspace::WORKSPACE_DB .set_toolchain( workspace_id, - worktree_id, + worktree_root_path, relative_path.clone(), toolchain.clone(), ) diff --git a/crates/toolchain_selector/src/toolchain_selector.rs b/crates/toolchain_selector/src/toolchain_selector.rs index f7262c248f15f0f68fcd7a903ee01cac6b22d0af..36ef2b960a8abfe684628cea465b68e6eab5e463 100644 --- a/crates/toolchain_selector/src/toolchain_selector.rs +++ b/crates/toolchain_selector/src/toolchain_selector.rs @@ -1,6 +1,7 @@ mod active_toolchain; pub use active_toolchain::ActiveToolchain; +use anyhow::Context as _; use convert_case::Casing as _; use editor::Editor; use file_finder::OpenPathDelegate; @@ -62,6 +63,7 @@ struct AddToolchainState { language_name: LanguageName, root_path: ProjectPath, weak: WeakEntity, + worktree_root_path: Arc, } struct ScopePickerState { @@ -99,12 +101,17 @@ impl AddToolchainState { root_path: ProjectPath, window: &mut Window, cx: &mut Context, - ) -> Entity { + ) -> anyhow::Result> { let weak = cx.weak_entity(); - - cx.new(|cx| { + let worktree_root_path = project + .read(cx) + .worktree_for_id(root_path.worktree_id, cx) + .map(|worktree| worktree.read(cx).abs_path()) + .context("Could not find worktree")?; + Ok(cx.new(|cx| { let (lister, rx) = Self::create_path_browser_delegate(project.clone(), cx); let picker = cx.new(|cx| Picker::uniform_list(lister, window, cx)); + Self { state: AddState::Path { _subscription: cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| { @@ -118,8 +125,9 @@ impl AddToolchainState { language_name, root_path, weak, + worktree_root_path, } - }) + })) } fn create_path_browser_delegate( @@ -237,7 +245,15 @@ impl AddToolchainState { // Suggest a default scope based on the applicability. let scope = if let Some(project_path) = resolved_toolchain_path { if !root_path.path.as_ref().is_empty() && project_path.starts_with(&root_path) { - ToolchainScope::Subproject(root_path.worktree_id, root_path.path) + let worktree_root_path = project + .read_with(cx, |this, cx| { + this.worktree_for_id(root_path.worktree_id, cx) + .map(|worktree| worktree.read(cx).abs_path()) + }) + .ok() + .flatten() + .context("Could not find a worktree with a given worktree ID")?; + ToolchainScope::Subproject(worktree_root_path, root_path.path) } else { ToolchainScope::Project } @@ -400,7 +416,7 @@ impl Render for AddToolchainState { ToolchainScope::Global, ToolchainScope::Project, ToolchainScope::Subproject( - self.root_path.worktree_id, + self.worktree_root_path.clone(), self.root_path.path.clone(), ), ]; @@ -693,7 +709,7 @@ impl ToolchainSelector { cx: &mut Context, ) { if matches!(self.state, State::Search(_)) { - self.state = State::AddToolchain(AddToolchainState::new( + let Ok(state) = AddToolchainState::new( self.project.clone(), self.language_name.clone(), ProjectPath { @@ -702,7 +718,10 @@ impl ToolchainSelector { }, window, cx, - )); + ) else { + return; + }; + self.state = State::AddToolchain(state); self.state.focus_handle(cx).focus(window, cx); cx.notify(); } @@ -899,11 +918,17 @@ impl PickerDelegate for ToolchainSelectorDelegate { { let workspace = self.workspace.clone(); let worktree_id = self.worktree_id; + let worktree_abs_path_root = self.worktree_abs_path_root.clone(); let path = self.relative_path.clone(); let relative_path = self.relative_path.clone(); cx.spawn_in(window, async move |_, cx| { workspace::WORKSPACE_DB - .set_toolchain(workspace_id, worktree_id, relative_path, toolchain.clone()) + .set_toolchain( + workspace_id, + worktree_abs_path_root, + relative_path, + toolchain.clone(), + ) .await .log_err(); workspace diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 094d03494e726677dc43235d96fc62c076673bf5..8d20339ec952020416e4b8d5846bf44f5f8e9b98 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -24,7 +24,6 @@ use project::{ }; use language::{LanguageName, Toolchain, ToolchainScope}; -use project::WorktreeId; use remote::{ DockerConnectionOptions, RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions, }; @@ -845,6 +844,44 @@ impl Domain for WorkspaceDb { host_name TEXT ) STRICT; ), + sql!(CREATE TABLE toolchains2 ( + workspace_id INTEGER, + worktree_root_path TEXT NOT NULL, + language_name TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + raw_json TEXT NOT NULL, + relative_worktree_path TEXT NOT NULL, + PRIMARY KEY (workspace_id, worktree_root_path, language_name, relative_worktree_path)) STRICT; + INSERT OR REPLACE INTO toolchains2 + // The `instr(paths, '\n') = 0` part allows us to find all + // workspaces that have a single worktree, as `\n` is used as a + // separator when serializing the workspace paths, so if no `\n` is + // found, we know we have a single worktree. + SELECT toolchains.workspace_id, paths, language_name, name, path, raw_json, relative_worktree_path FROM toolchains INNER JOIN workspaces ON toolchains.workspace_id = workspaces.workspace_id AND instr(paths, '\n') = 0; + DROP TABLE toolchains; + ALTER TABLE toolchains2 RENAME TO toolchains; + ), + sql!(CREATE TABLE user_toolchains2 ( + remote_connection_id INTEGER, + workspace_id INTEGER NOT NULL, + worktree_root_path TEXT NOT NULL, + relative_worktree_path TEXT NOT NULL, + language_name TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + raw_json TEXT NOT NULL, + + PRIMARY KEY (workspace_id, worktree_root_path, relative_worktree_path, language_name, name, path, raw_json)) STRICT; + INSERT OR REPLACE INTO user_toolchains2 + // The `instr(paths, '\n') = 0` part allows us to find all + // workspaces that have a single worktree, as `\n` is used as a + // separator when serializing the workspace paths, so if no `\n` is + // found, we know we have a single worktree. + SELECT user_toolchains.remote_connection_id, user_toolchains.workspace_id, paths, relative_worktree_path, language_name, name, path, raw_json FROM user_toolchains INNER JOIN workspaces ON user_toolchains.workspace_id = workspaces.workspace_id AND instr(paths, '\n') = 0; + DROP TABLE user_toolchains; + ALTER TABLE user_toolchains2 RENAME TO user_toolchains; + ), ]; // Allow recovering from bad migration that was initially shipped to nightly @@ -1030,11 +1067,11 @@ impl WorkspaceDb { workspace_id: WorkspaceId, remote_connection_id: Option, ) -> BTreeMap> { - type RowKind = (WorkspaceId, u64, String, String, String, String, String); + type RowKind = (WorkspaceId, String, String, String, String, String, String); let toolchains: Vec = self .select_bound(sql! { - SELECT workspace_id, worktree_id, relative_worktree_path, + SELECT workspace_id, worktree_root_path, relative_worktree_path, language_name, name, path, raw_json FROM user_toolchains WHERE remote_connection_id IS ?1 AND ( workspace_id IN (0, ?2) @@ -1048,7 +1085,7 @@ impl WorkspaceDb { for ( _workspace_id, - worktree_id, + worktree_root_path, relative_worktree_path, language_name, name, @@ -1058,22 +1095,24 @@ impl WorkspaceDb { { // INTEGER's that are primary keys (like workspace ids, remote connection ids and such) start at 1, so we're safe to let scope = if _workspace_id == WorkspaceId(0) { - debug_assert_eq!(worktree_id, u64::MAX); + debug_assert_eq!(worktree_root_path, String::default()); debug_assert_eq!(relative_worktree_path, String::default()); ToolchainScope::Global } else { debug_assert_eq!(workspace_id, _workspace_id); debug_assert_eq!( - worktree_id == u64::MAX, + worktree_root_path == String::default(), relative_worktree_path == String::default() ); let Some(relative_path) = RelPath::unix(&relative_worktree_path).log_err() else { continue; }; - if worktree_id != u64::MAX && relative_worktree_path != String::default() { + if worktree_root_path != String::default() + && relative_worktree_path != String::default() + { ToolchainScope::Subproject( - WorktreeId::from_usize(worktree_id as usize), + Arc::from(worktree_root_path.as_ref()), relative_path.into(), ) } else { @@ -1159,13 +1198,13 @@ impl WorkspaceDb { for (scope, toolchains) in workspace.user_toolchains { for toolchain in toolchains { - let query = sql!(INSERT OR REPLACE INTO user_toolchains(remote_connection_id, workspace_id, worktree_id, relative_worktree_path, language_name, name, path, raw_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)); - let (workspace_id, worktree_id, relative_worktree_path) = match scope { - ToolchainScope::Subproject(worktree_id, ref path) => (Some(workspace.id), Some(worktree_id), Some(path.as_unix_str().to_owned())), + let query = sql!(INSERT OR REPLACE INTO user_toolchains(remote_connection_id, workspace_id, worktree_root_path, relative_worktree_path, language_name, name, path, raw_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)); + let (workspace_id, worktree_root_path, relative_worktree_path) = match scope { + ToolchainScope::Subproject(ref worktree_root_path, ref path) => (Some(workspace.id), Some(worktree_root_path.to_string_lossy().into_owned()), Some(path.as_unix_str().to_owned())), ToolchainScope::Project => (Some(workspace.id), None, None), ToolchainScope::Global => (None, None, None), }; - let args = (remote_connection_id, workspace_id.unwrap_or(WorkspaceId(0)), worktree_id.map_or(usize::MAX,|id| id.to_usize()), relative_worktree_path.unwrap_or_default(), + let args = (remote_connection_id, workspace_id.unwrap_or(WorkspaceId(0)), worktree_root_path.unwrap_or_default(), relative_worktree_path.unwrap_or_default(), toolchain.language_name.as_ref().to_owned(), toolchain.name.to_string(), toolchain.path.to_string(), toolchain.as_json.to_string()); if let Err(err) = conn.exec_bound(query)?(args) { log::error!("{err}"); @@ -1844,24 +1883,24 @@ impl WorkspaceDb { pub(crate) async fn toolchains( &self, workspace_id: WorkspaceId, - ) -> Result)>> { + ) -> Result, Arc)>> { self.write(move |this| { let mut select = this .select_bound(sql!( SELECT - name, path, worktree_id, relative_worktree_path, language_name, raw_json + name, path, worktree_root_path, relative_worktree_path, language_name, raw_json FROM toolchains WHERE workspace_id = ? )) .context("select toolchains")?; - let toolchain: Vec<(String, String, u64, String, String, String)> = + let toolchain: Vec<(String, String, String, String, String, String)> = select(workspace_id)?; Ok(toolchain .into_iter() .filter_map( - |(name, path, worktree_id, relative_worktree_path, language, json)| { + |(name, path, worktree_root_path, relative_worktree_path, language, json)| { Some(( Toolchain { name: name.into(), @@ -1869,7 +1908,7 @@ impl WorkspaceDb { language_name: LanguageName::new(&language), as_json: serde_json::Value::from_str(&json).ok()?, }, - WorktreeId::from_proto(worktree_id), + Arc::from(worktree_root_path.as_ref()), RelPath::from_proto(&relative_worktree_path).log_err()?, )) }, @@ -1882,18 +1921,18 @@ impl WorkspaceDb { pub async fn set_toolchain( &self, workspace_id: WorkspaceId, - worktree_id: WorktreeId, + worktree_root_path: Arc, relative_worktree_path: Arc, toolchain: Toolchain, ) -> Result<()> { log::debug!( - "Setting toolchain for workspace, worktree: {worktree_id:?}, relative path: {relative_worktree_path:?}, toolchain: {}", + "Setting toolchain for workspace, worktree: {worktree_root_path:?}, relative path: {relative_worktree_path:?}, toolchain: {}", toolchain.name ); self.write(move |conn| { let mut insert = conn .exec_bound(sql!( - INSERT INTO toolchains(workspace_id, worktree_id, relative_worktree_path, language_name, name, path, raw_json) VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO toolchains(workspace_id, worktree_root_path, relative_worktree_path, language_name, name, path, raw_json) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO UPDATE SET name = ?5, @@ -1904,7 +1943,7 @@ impl WorkspaceDb { insert(( workspace_id, - worktree_id.to_usize(), + worktree_root_path.to_string_lossy().into_owned(), relative_worktree_path.as_unix_str(), toolchain.language_name.as_ref(), toolchain.name.as_ref(), diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 139fa88359c574e1565ed778fdfbd5fa1c8f7944..53b0cc0623fa4b3ce7de5f1d8e3fd2262210a09a 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1697,8 +1697,22 @@ impl Workspace { let toolchains = DB.toolchains(workspace_id).await?; - for (toolchain, worktree_id, path) in toolchains { + for (toolchain, worktree_path, path) in toolchains { let toolchain_path = PathBuf::from(toolchain.path.clone().to_string()); + let Some(worktree_id) = project_handle.read_with(cx, |this, cx| { + this.find_worktree(&worktree_path, cx) + .and_then(|(worktree, rel_path)| { + if rel_path.is_empty() { + Some(worktree.read(cx).id()) + } else { + None + } + }) + })? + else { + // We did not find a worktree with a given path, but that's whatever. + continue; + }; if !app_state.fs.is_file(toolchain_path.as_path()).await { continue; } @@ -8217,9 +8231,22 @@ async fn open_remote_project_inner( cx: &mut AsyncApp, ) -> Result>>> { let toolchains = DB.toolchains(workspace_id).await?; - for (toolchain, worktree_id, path) in toolchains { + for (toolchain, worktree_path, path) in toolchains { project .update(cx, |this, cx| { + let Some(worktree_id) = + this.find_worktree(&worktree_path, cx) + .and_then(|(worktree, rel_path)| { + if rel_path.is_empty() { + Some(worktree.read(cx).id()) + } else { + None + } + }) + else { + return Task::ready(None); + }; + this.activate_toolchain(ProjectPath { worktree_id, path }, toolchain, cx) })? .await; From 62d36b22fd1c497ae586f89f21b7a80ea6d8091a Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 19 Dec 2025 10:25:19 -0300 Subject: [PATCH 05/19] gpui: Add `text_ellipsis_start` method (#45122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR is an additive change introducing the `truncate_start` method to labels, which gives us the ability to add an ellipsis at the beginning of the text as opposed to the regular `truncate`. This will be generally used for truncating file paths, where the end is typically more relevant than the beginning, but given it's a general method, there's the possibility to be used anywhere else, too. Screenshot 2025-12-17 at 12  35@2x Release Notes: - N/A --------- Co-authored-by: Lukas Wirth --- crates/editor/src/code_context_menus.rs | 8 +- crates/gpui/src/elements/text.rs | 14 +- crates/gpui/src/style.rs | 8 +- crates/gpui/src/styled.rs | 10 +- crates/gpui/src/text_system/line_wrapper.rs | 228 ++++++++++++++++--- crates/ui/src/components/label/label.rs | 9 +- crates/ui/src/components/label/label_like.rs | 13 +- 7 files changed, 248 insertions(+), 42 deletions(-) diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index 96739defc506414f573e2454dc31f9c32d8e4adf..e5520be88e34307220126ebafdba6c6371a5db12 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -1615,8 +1615,12 @@ impl CodeActionsMenu { window.text_style().font(), window.text_style().font_size.to_pixels(window.rem_size()), ); - let is_truncated = - line_wrapper.should_truncate_line(&label, CODE_ACTION_MENU_MAX_WIDTH, "…"); + let is_truncated = line_wrapper.should_truncate_line( + &label, + CODE_ACTION_MENU_MAX_WIDTH, + "…", + gpui::TruncateFrom::End, + ); if is_truncated.is_none() { return None; diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 1b1bfd778c7bc746c67551eb31cf70f60b1485ea..942a0a326526431dc65f389e9cff67bac252d571 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -2,8 +2,8 @@ use crate::{ ActiveTooltip, AnyView, App, Bounds, DispatchPhase, Element, ElementId, GlobalElementId, HighlightStyle, Hitbox, HitboxBehavior, InspectorElementId, IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, SharedString, Size, TextOverflow, - TextRun, TextStyle, TooltipId, WhiteSpace, Window, WrappedLine, WrappedLineLayout, - register_tooltip_mouse_handlers, set_tooltip_on_window, + TextRun, TextStyle, TooltipId, TruncateFrom, WhiteSpace, Window, WrappedLine, + WrappedLineLayout, register_tooltip_mouse_handlers, set_tooltip_on_window, }; use anyhow::Context as _; use itertools::Itertools; @@ -354,7 +354,7 @@ impl TextLayout { None }; - let (truncate_width, truncation_suffix) = + let (truncate_width, truncation_affix, truncate_from) = if let Some(text_overflow) = text_style.text_overflow.clone() { let width = known_dimensions.width.or(match available_space.width { crate::AvailableSpace::Definite(x) => match text_style.line_clamp { @@ -365,10 +365,11 @@ impl TextLayout { }); match text_overflow { - TextOverflow::Truncate(s) => (width, s), + TextOverflow::Truncate(s) => (width, s, TruncateFrom::End), + TextOverflow::TruncateStart(s) => (width, s, TruncateFrom::Start), } } else { - (None, "".into()) + (None, "".into(), TruncateFrom::End) }; if let Some(text_layout) = element_state.0.borrow().as_ref() @@ -383,8 +384,9 @@ impl TextLayout { line_wrapper.truncate_line( text.clone(), truncate_width, - &truncation_suffix, + &truncation_affix, &runs, + truncate_from, ) } else { (text.clone(), Cow::Borrowed(&*runs)) diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index 4d6e6f490d81d967692a3e9d8316af75a7a4d306..7481b8001e5752599b90625450d7adb0c66ea2ca 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -334,9 +334,13 @@ pub enum WhiteSpace { /// How to truncate text that overflows the width of the element #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub enum TextOverflow { - /// Truncate the text when it doesn't fit, and represent this truncation by displaying the - /// provided string. + /// Truncate the text at the end when it doesn't fit, and represent this truncation by + /// displaying the provided string (e.g., "very long te…"). Truncate(SharedString), + /// Truncate the text at the start when it doesn't fit, and represent this truncation by + /// displaying the provided string at the beginning (e.g., "…ong text here"). + /// Typically more adequate for file paths where the end is more important than the beginning. + TruncateStart(SharedString), } /// How to align text within the element diff --git a/crates/gpui/src/styled.rs b/crates/gpui/src/styled.rs index e8088a84d7fc141d0a320988c6399afe2b93ce07..c5eef0d4496edea4d30c665c82dc0a9f00bb83be 100644 --- a/crates/gpui/src/styled.rs +++ b/crates/gpui/src/styled.rs @@ -75,13 +75,21 @@ pub trait Styled: Sized { self } - /// Sets the truncate overflowing text with an ellipsis (…) if needed. + /// Sets the truncate overflowing text with an ellipsis (…) at the end if needed. /// [Docs](https://tailwindcss.com/docs/text-overflow#ellipsis) fn text_ellipsis(mut self) -> Self { self.text_style().text_overflow = Some(TextOverflow::Truncate(ELLIPSIS)); self } + /// Sets the truncate overflowing text with an ellipsis (…) at the start if needed. + /// Typically more adequate for file paths where the end is more important than the beginning. + /// Note: This doesn't exist in Tailwind CSS. + fn text_ellipsis_start(mut self) -> Self { + self.text_style().text_overflow = Some(TextOverflow::TruncateStart(ELLIPSIS)); + self + } + /// Sets the text overflow behavior of the element. fn text_overflow(mut self, overflow: TextOverflow) -> Self { self.text_style().text_overflow = Some(overflow); diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index 95cd55d04443c6b2c351bf8533ccb57d49e8dcd9..457316f353a48fa112de1736b2b7eaa2d4c72313 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -2,6 +2,15 @@ use crate::{FontId, FontRun, Pixels, PlatformTextSystem, SharedString, TextRun, use collections::HashMap; use std::{borrow::Cow, iter, sync::Arc}; +/// Determines whether to truncate text from the start or end. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TruncateFrom { + /// Truncate text from the start. + Start, + /// Truncate text from the end. + End, +} + /// The GPUI line wrapper, used to wrap lines of text to a given width. pub struct LineWrapper { platform_text_system: Arc, @@ -129,29 +138,50 @@ impl LineWrapper { } /// Determines if a line should be truncated based on its width. + /// + /// Returns the truncation index in `line`. pub fn should_truncate_line( &mut self, line: &str, truncate_width: Pixels, - truncation_suffix: &str, + truncation_affix: &str, + truncate_from: TruncateFrom, ) -> Option { let mut width = px(0.); - let suffix_width = truncation_suffix + let suffix_width = truncation_affix .chars() .map(|c| self.width_for_char(c)) .fold(px(0.0), |a, x| a + x); let mut truncate_ix = 0; - for (ix, c) in line.char_indices() { - if width + suffix_width < truncate_width { - truncate_ix = ix; + match truncate_from { + TruncateFrom::Start => { + for (ix, c) in line.char_indices().rev() { + if width + suffix_width < truncate_width { + truncate_ix = ix; + } + + let char_width = self.width_for_char(c); + width += char_width; + + if width.floor() > truncate_width { + return Some(truncate_ix); + } + } } + TruncateFrom::End => { + for (ix, c) in line.char_indices() { + if width + suffix_width < truncate_width { + truncate_ix = ix; + } - let char_width = self.width_for_char(c); - width += char_width; + let char_width = self.width_for_char(c); + width += char_width; - if width.floor() > truncate_width { - return Some(truncate_ix); + if width.floor() > truncate_width { + return Some(truncate_ix); + } + } } } @@ -163,16 +193,23 @@ impl LineWrapper { &mut self, line: SharedString, truncate_width: Pixels, - truncation_suffix: &str, + truncation_affix: &str, runs: &'a [TextRun], + truncate_from: TruncateFrom, ) -> (SharedString, Cow<'a, [TextRun]>) { if let Some(truncate_ix) = - self.should_truncate_line(&line, truncate_width, truncation_suffix) + self.should_truncate_line(&line, truncate_width, truncation_affix, truncate_from) { - let result = - SharedString::from(format!("{}{}", &line[..truncate_ix], truncation_suffix)); + let result = match truncate_from { + TruncateFrom::Start => { + SharedString::from(format!("{truncation_affix}{}", &line[truncate_ix + 1..])) + } + TruncateFrom::End => { + SharedString::from(format!("{}{truncation_affix}", &line[..truncate_ix])) + } + }; let mut runs = runs.to_vec(); - update_runs_after_truncation(&result, truncation_suffix, &mut runs); + update_runs_after_truncation(&result, truncation_affix, &mut runs, truncate_from); (result, Cow::Owned(runs)) } else { (line, Cow::Borrowed(runs)) @@ -245,15 +282,35 @@ impl LineWrapper { } } -fn update_runs_after_truncation(result: &str, ellipsis: &str, runs: &mut Vec) { +fn update_runs_after_truncation( + result: &str, + ellipsis: &str, + runs: &mut Vec, + truncate_from: TruncateFrom, +) { let mut truncate_at = result.len() - ellipsis.len(); - for (run_index, run) in runs.iter_mut().enumerate() { - if run.len <= truncate_at { - truncate_at -= run.len; - } else { - run.len = truncate_at + ellipsis.len(); - runs.truncate(run_index + 1); - break; + match truncate_from { + TruncateFrom::Start => { + for (run_index, run) in runs.iter_mut().enumerate().rev() { + if run.len <= truncate_at { + truncate_at -= run.len; + } else { + run.len = truncate_at + ellipsis.len(); + runs.splice(..run_index, std::iter::empty()); + break; + } + } + } + TruncateFrom::End => { + for (run_index, run) in runs.iter_mut().enumerate() { + if run.len <= truncate_at { + truncate_at -= run.len; + } else { + run.len = truncate_at + ellipsis.len(); + runs.truncate(run_index + 1); + break; + } + } } } } @@ -503,7 +560,7 @@ mod tests { } #[test] - fn test_truncate_line() { + fn test_truncate_line_end() { let mut wrapper = build_wrapper(); fn perform_test( @@ -514,8 +571,13 @@ mod tests { ) { let dummy_run_lens = vec![text.len()]; let dummy_runs = generate_test_runs(&dummy_run_lens); - let (result, dummy_runs) = - wrapper.truncate_line(text.into(), px(220.), ellipsis, &dummy_runs); + let (result, dummy_runs) = wrapper.truncate_line( + text.into(), + px(220.), + ellipsis, + &dummy_runs, + TruncateFrom::End, + ); assert_eq!(result, expected); assert_eq!(dummy_runs.first().unwrap().len, result.len()); } @@ -541,7 +603,50 @@ mod tests { } #[test] - fn test_truncate_multiple_runs() { + fn test_truncate_line_start() { + let mut wrapper = build_wrapper(); + + fn perform_test( + wrapper: &mut LineWrapper, + text: &'static str, + expected: &'static str, + ellipsis: &str, + ) { + let dummy_run_lens = vec![text.len()]; + let dummy_runs = generate_test_runs(&dummy_run_lens); + let (result, dummy_runs) = wrapper.truncate_line( + text.into(), + px(220.), + ellipsis, + &dummy_runs, + TruncateFrom::Start, + ); + assert_eq!(result, expected); + assert_eq!(dummy_runs.first().unwrap().len, result.len()); + } + + perform_test( + &mut wrapper, + "aaaa bbbb cccc ddddd eeee fff gg", + "cccc ddddd eeee fff gg", + "", + ); + perform_test( + &mut wrapper, + "aaaa bbbb cccc ddddd eeee fff gg", + "…ccc ddddd eeee fff gg", + "…", + ); + perform_test( + &mut wrapper, + "aaaa bbbb cccc ddddd eeee fff gg", + "......dddd eeee fff gg", + "......", + ); + } + + #[test] + fn test_truncate_multiple_runs_end() { let mut wrapper = build_wrapper(); fn perform_test( @@ -554,7 +659,7 @@ mod tests { ) { let dummy_runs = generate_test_runs(run_lens); let (result, dummy_runs) = - wrapper.truncate_line(text.into(), line_width, "…", &dummy_runs); + wrapper.truncate_line(text.into(), line_width, "…", &dummy_runs, TruncateFrom::End); assert_eq!(result, expected); for (run, result_len) in dummy_runs.iter().zip(result_run_len) { assert_eq!(run.len, *result_len); @@ -600,10 +705,75 @@ mod tests { } #[test] - fn test_update_run_after_truncation() { + fn test_truncate_multiple_runs_start() { + let mut wrapper = build_wrapper(); + + #[track_caller] + fn perform_test( + wrapper: &mut LineWrapper, + text: &'static str, + expected: &str, + run_lens: &[usize], + result_run_len: &[usize], + line_width: Pixels, + ) { + let dummy_runs = generate_test_runs(run_lens); + let (result, dummy_runs) = wrapper.truncate_line( + text.into(), + line_width, + "…", + &dummy_runs, + TruncateFrom::Start, + ); + assert_eq!(result, expected); + for (run, result_len) in dummy_runs.iter().zip(result_run_len) { + assert_eq!(run.len, *result_len); + } + } + // Case 0: Normal + // Text: abcdefghijkl + // Runs: Run0 { len: 12, ... } + // + // Truncate res: …ijkl (truncate_at = 9) + // Run res: Run0 { string: …ijkl, len: 7, ... } + perform_test(&mut wrapper, "abcdefghijkl", "…ijkl", &[12], &[7], px(50.)); + // Case 1: Drop some runs + // Text: abcdefghijkl + // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... } + // + // Truncate res: …ghijkl (truncate_at = 7) + // Runs res: Run0 { string: …gh, len: 5, ... }, Run1 { string: ijkl, len: + // 4, ... } + perform_test( + &mut wrapper, + "abcdefghijkl", + "…ghijkl", + &[4, 4, 4], + &[5, 4], + px(70.), + ); + // Case 2: Truncate at start of some run + // Text: abcdefghijkl + // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... } + // + // Truncate res: abcdefgh… (truncate_at = 3) + // Runs res: Run0 { string: …, len: 3, ... }, Run1 { string: efgh, len: + // 4, ... }, Run2 { string: ijkl, len: 4, ... } + perform_test( + &mut wrapper, + "abcdefghijkl", + "…efghijkl", + &[4, 4, 4], + &[3, 4, 4], + px(90.), + ); + } + + #[test] + fn test_update_run_after_truncation_end() { fn perform_test(result: &str, run_lens: &[usize], result_run_lens: &[usize]) { let mut dummy_runs = generate_test_runs(run_lens); - update_runs_after_truncation(result, "…", &mut dummy_runs); + update_runs_after_truncation(result, "…", &mut dummy_runs, TruncateFrom::End); for (run, result_len) in dummy_runs.iter().zip(result_run_lens) { assert_eq!(run.len, *result_len); } diff --git a/crates/ui/src/components/label/label.rs b/crates/ui/src/components/label/label.rs index 49e2de94a1f86196c10e41879797b02070517e65..d0f50c00336eb971621e2da7bbaf53cf09569caa 100644 --- a/crates/ui/src/components/label/label.rs +++ b/crates/ui/src/components/label/label.rs @@ -56,6 +56,12 @@ impl Label { pub fn set_text(&mut self, text: impl Into) { self.label = text.into(); } + + /// Truncates the label from the start, keeping the end visible. + pub fn truncate_start(mut self) -> Self { + self.base = self.base.truncate_start(); + self + } } // Style methods. @@ -256,7 +262,8 @@ impl Component for Label { "Special Cases", vec![ single_example("Single Line", Label::new("Line 1\nLine 2\nLine 3").single_line().into_any_element()), - single_example("Text Ellipsis", div().max_w_24().child(Label::new("This is a very long file name that should be truncated: very_long_file_name_with_many_words.rs").truncate()).into_any_element()), + single_example("Regular Truncation", div().max_w_24().child(Label::new("This is a very long file name that should be truncated: very_long_file_name_with_many_words.rs").truncate()).into_any_element()), + single_example("Start Truncation", div().max_w_24().child(Label::new("zed/crates/ui/src/components/label/truncate/label/label.rs").truncate_start()).into_any_element()), ], ), ]) diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index 31fb7bfd88f1343ac6145c86f228bdcbd6a22e10..10d54845dabf371b8da6fed5ebbcd2b8d82ea711 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -88,6 +88,7 @@ pub struct LabelLike { underline: bool, single_line: bool, truncate: bool, + truncate_start: bool, } impl Default for LabelLike { @@ -113,6 +114,7 @@ impl LabelLike { underline: false, single_line: false, truncate: false, + truncate_start: false, } } } @@ -126,6 +128,12 @@ impl LabelLike { gpui::margin_style_methods!({ visibility: pub }); + + /// Truncates overflowing text with an ellipsis (`…`) at the start if needed. + pub fn truncate_start(mut self) -> Self { + self.truncate_start = true; + self + } } impl LabelCommon for LabelLike { @@ -169,7 +177,7 @@ impl LabelCommon for LabelLike { self } - /// Truncates overflowing text with an ellipsis (`…`) if needed. + /// Truncates overflowing text with an ellipsis (`…`) at the end if needed. fn truncate(mut self) -> Self { self.truncate = true; self @@ -235,6 +243,9 @@ impl RenderOnce for LabelLike { .when(self.truncate, |this| { this.overflow_x_hidden().text_ellipsis() }) + .when(self.truncate_start, |this| { + this.overflow_x_hidden().text_ellipsis_start() + }) .text_color(color) .font_weight( self.weight From 4e0471cf663b88f38246440f5ae06ca4828225d8 Mon Sep 17 00:00:00 2001 From: ozzy <109994179+ddoemonn@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:50:35 +0300 Subject: [PATCH 06/19] git panel: Truncate file paths from the left (#43462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/user-attachments/assets/758e1ec9-6c34-4e13-b605-cf00c18ca16f Release Notes: - Improved: Git panel now truncates long file paths from the left, showing "…path/filename" when space is limited, keeping filenames always visible. @cole-miller @mattermill --------- Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Co-authored-by: Danilo Leal --- crates/git_ui/src/git_panel.rs | 2 +- crates/ui/src/components/label/label_like.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 532f9a099a823796706be48ed14cc7da820c5d8b..1323ee014f76ebde42b8dff436b2abed851d13f0 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -5203,7 +5203,7 @@ impl GitPanel { this.child( self.entry_label(path_name, path_color) - .truncate() + .truncate_start() .when(strikethrough, Label::strikethrough), ) }) diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index 10d54845dabf371b8da6fed5ebbcd2b8d82ea711..f6e7a1b893d54fff425618d5c604f591144a7385 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -56,7 +56,7 @@ pub trait LabelCommon { /// Sets the alpha property of the label, overwriting the alpha value of the color. fn alpha(self, alpha: f32) -> Self; - /// Truncates overflowing text with an ellipsis (`…`) if needed. + /// Truncates overflowing text with an ellipsis (`…`) at the end if needed. fn truncate(self) -> Self; /// Sets the label to render as a single line. From ae44c3c8811472dfb105932eaf4e24a2f2853a4e Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Fri, 19 Dec 2025 09:39:58 -0500 Subject: [PATCH 07/19] Fix extra terminal being created when a task replaces a terminal in the center pane (#45317) Closes https://github.com/zed-industries/zed/issues/21144 Release Notes: - Fixed spawned tasks creating an extra terminal in the dock in some cases. --- crates/terminal_view/src/terminal_panel.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index ed43d94e9d3d7c08c1ff4570e08726310360cd93..738a0b4502642423377bdf69b49d26250536761f 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -939,7 +939,6 @@ impl TerminalPanel { cx: &mut Context, ) -> Task>> { let reveal = spawn_task.reveal; - let reveal_target = spawn_task.reveal_target; let task_workspace = self.workspace.clone(); cx.spawn_in(window, async move |terminal_panel, cx| { let project = terminal_panel.update(cx, |this, cx| { @@ -955,6 +954,14 @@ impl TerminalPanel { terminal_to_replace.set_terminal(new_terminal.clone(), window, cx); })?; + let reveal_target = terminal_panel.update(cx, |panel, _| { + if panel.center.panes().iter().any(|p| **p == task_pane) { + RevealTarget::Dock + } else { + RevealTarget::Center + } + })?; + match reveal { RevealStrategy::Always => match reveal_target { RevealTarget::Center => { From 7427924405dabda02cca87c24143f2d5f27eb433 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, 19 Dec 2025 16:03:35 +0100 Subject: [PATCH 08/19] adjusted scheduler prioritization algorithm (#45367) This fixes a number of issues where zed depends on the order of polling which changed when switching scheduler. We have adjusted the algorithm so it matches the previous order while keeping the prioritization feature. Release Notes: - N/A --- crates/gpui/src/queue.rs | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/crates/gpui/src/queue.rs b/crates/gpui/src/queue.rs index 9e9da710977ee80df1853791918eebe5e7f01096..1ecec183c6c58a86e305343f7bcd1056cda7a581 100644 --- a/crates/gpui/src/queue.rs +++ b/crates/gpui/src/queue.rs @@ -1,4 +1,5 @@ use std::{ + collections::VecDeque, fmt, iter::FusedIterator, sync::{Arc, atomic::AtomicUsize}, @@ -9,9 +10,9 @@ use rand::{Rng, SeedableRng, rngs::SmallRng}; use crate::Priority; struct PriorityQueues { - high_priority: Vec, - medium_priority: Vec, - low_priority: Vec, + high_priority: VecDeque, + medium_priority: VecDeque, + low_priority: VecDeque, } impl PriorityQueues { @@ -42,9 +43,9 @@ impl PriorityQueueState { let mut queues = self.queues.lock(); match priority { Priority::Realtime(_) => unreachable!(), - Priority::High => queues.high_priority.push(item), - Priority::Medium => queues.medium_priority.push(item), - Priority::Low => queues.low_priority.push(item), + Priority::High => queues.high_priority.push_back(item), + Priority::Medium => queues.medium_priority.push_back(item), + Priority::Low => queues.low_priority.push_back(item), }; self.condvar.notify_one(); Ok(()) @@ -141,9 +142,9 @@ impl PriorityQueueReceiver { pub(crate) fn new() -> (PriorityQueueSender, Self) { let state = PriorityQueueState { queues: parking_lot::Mutex::new(PriorityQueues { - high_priority: Vec::new(), - medium_priority: Vec::new(), - low_priority: Vec::new(), + high_priority: VecDeque::new(), + medium_priority: VecDeque::new(), + low_priority: VecDeque::new(), }), condvar: parking_lot::Condvar::new(), receiver_count: AtomicUsize::new(1), @@ -226,7 +227,7 @@ impl PriorityQueueReceiver { if !queues.high_priority.is_empty() { let flip = self.rand.random_ratio(P::High.probability(), mass); if flip { - return Ok(queues.high_priority.pop()); + return Ok(queues.high_priority.pop_front()); } mass -= P::High.probability(); } @@ -234,7 +235,7 @@ impl PriorityQueueReceiver { if !queues.medium_priority.is_empty() { let flip = self.rand.random_ratio(P::Medium.probability(), mass); if flip { - return Ok(queues.medium_priority.pop()); + return Ok(queues.medium_priority.pop_front()); } mass -= P::Medium.probability(); } @@ -242,7 +243,7 @@ impl PriorityQueueReceiver { if !queues.low_priority.is_empty() { let flip = self.rand.random_ratio(P::Low.probability(), mass); if flip { - return Ok(queues.low_priority.pop()); + return Ok(queues.low_priority.pop_front()); } } From b603372f44a60f8ed98ae95b36094582ead47b89 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 19 Dec 2025 16:06:28 +0100 Subject: [PATCH 09/19] Reduce GPU usage by activating VRR optimization only during high-rate input (#45369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #29073 This PR reduces unnecessary GPU usage by being more selective about when we present frames to prevent display underclocking (VRR optimization). ## Problem Previously, we would keep presenting frames for 1 second after *any* input event, regardless of whether it triggered a re-render. This caused unnecessary GPU work when the user was idle or during low-frequency interactions. ## Solution 1. **Only track input that triggers re-renders**: We now only record input timestamps when the input actually causes the window to become dirty, rather than on every input event. 2. **Rate-based activation**: The VRR optimization now only activates when input arrives at a high rate (≥ 60fps over the last 100ms). This means casual mouse movements or occasional keystrokes won't trigger continuous frame presentation. 3. **Sustained optimization**: Once high-rate input is detected (e.g., during scrolling or dragging), we sustain frame presentation for 1 second to prevent display underclocking, even if input briefly pauses. ## Implementation Added `InputRateTracker` which: - Tracks input timestamps in a 100ms sliding window - Activates when the window contains ≥ 6 events (60fps × 0.1s) - Extends a `sustain_until` timestamp by 1 second each time high rate is detected Release Notes: - Reduced GPU usage when idle by only presenting frames during bursts of high-frequency input. --- crates/gpui/src/window.rs | 70 +++++++++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 11 deletions(-) diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 2ccd7edac86bced89048cbe5dbf196d8fbcf95f3..8df421feb968677be0abbb642a7127871881bcf3 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -876,7 +876,9 @@ pub struct Window { active: Rc>, hovered: Rc>, pub(crate) needs_present: Rc>, - pub(crate) last_input_timestamp: Rc>, + /// Tracks recent input event timestamps to determine if input is arriving at a high rate. + /// Used to selectively enable VRR optimization only when input rate exceeds 60fps. + pub(crate) input_rate_tracker: Rc>, last_input_modality: InputModality, pub(crate) refreshing: bool, pub(crate) activation_observers: SubscriberSet<(), AnyObserver>, @@ -897,6 +899,51 @@ struct ModifierState { saw_keystroke: bool, } +/// Tracks input event timestamps to determine if input is arriving at a high rate. +/// Used for selective VRR (Variable Refresh Rate) optimization. +#[derive(Clone, Debug)] +pub(crate) struct InputRateTracker { + timestamps: Vec, + window: Duration, + inputs_per_second: u32, + sustain_until: Instant, + sustain_duration: Duration, +} + +impl Default for InputRateTracker { + fn default() -> Self { + Self { + timestamps: Vec::new(), + window: Duration::from_millis(100), + inputs_per_second: 60, + sustain_until: Instant::now(), + sustain_duration: Duration::from_secs(1), + } + } +} + +impl InputRateTracker { + pub fn record_input(&mut self) { + let now = Instant::now(); + self.timestamps.push(now); + self.prune_old_timestamps(now); + + let min_events = self.inputs_per_second as u128 * self.window.as_millis() / 1000; + if self.timestamps.len() as u128 >= min_events { + self.sustain_until = now + self.sustain_duration; + } + } + + pub fn is_high_rate(&self) -> bool { + Instant::now() < self.sustain_until + } + + fn prune_old_timestamps(&mut self, now: Instant) { + self.timestamps + .retain(|&t| now.duration_since(t) <= self.window); + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum DrawPhase { None, @@ -1047,7 +1094,7 @@ impl Window { let hovered = Rc::new(Cell::new(platform_window.is_hovered())); let needs_present = Rc::new(Cell::new(false)); let next_frame_callbacks: Rc>> = Default::default(); - let last_input_timestamp = Rc::new(Cell::new(Instant::now())); + let input_rate_tracker = Rc::new(RefCell::new(InputRateTracker::default())); platform_window .request_decorations(window_decorations.unwrap_or(WindowDecorations::Server)); @@ -1075,7 +1122,7 @@ impl Window { let active = active.clone(); let needs_present = needs_present.clone(); let next_frame_callbacks = next_frame_callbacks.clone(); - let last_input_timestamp = last_input_timestamp.clone(); + let input_rate_tracker = input_rate_tracker.clone(); move |request_frame_options| { let next_frame_callbacks = next_frame_callbacks.take(); if !next_frame_callbacks.is_empty() { @@ -1088,12 +1135,12 @@ impl Window { .log_err(); } - // Keep presenting the current scene for 1 extra second since the - // last input to prevent the display from underclocking the refresh rate. + // Keep presenting if input was recently arriving at a high rate (>= 60fps). + // Once high-rate input is detected, we sustain presentation for 1 second + // to prevent display underclocking during active input. let needs_present = request_frame_options.require_presentation || needs_present.get() - || (active.get() - && last_input_timestamp.get().elapsed() < Duration::from_secs(1)); + || (active.get() && input_rate_tracker.borrow_mut().is_high_rate()); if invalidator.is_dirty() || request_frame_options.force_render { measure("frame duration", || { @@ -1101,7 +1148,6 @@ impl Window { .update(&mut cx, |_, window, cx| { let arena_clear_needed = window.draw(cx); window.present(); - // drop the arena elements after present to reduce latency arena_clear_needed.clear(); }) .log_err(); @@ -1299,7 +1345,7 @@ impl Window { active, hovered, needs_present, - last_input_timestamp, + input_rate_tracker, last_input_modality: InputModality::Mouse, refreshing: false, activation_observers: SubscriberSet::new(), @@ -3691,8 +3737,6 @@ impl Window { /// Dispatch a mouse or keyboard event on the window. #[profiling::function] pub fn dispatch_event(&mut self, event: PlatformInput, cx: &mut App) -> DispatchEventResult { - self.last_input_timestamp.set(Instant::now()); - // Track whether this input was keyboard-based for focus-visible styling self.last_input_modality = match &event { PlatformInput::KeyDown(_) | PlatformInput::ModifiersChanged(_) => { @@ -3793,6 +3837,10 @@ impl Window { self.dispatch_key_event(any_key_event, cx); } + if self.invalidator.is_dirty() { + self.input_rate_tracker.borrow_mut().record_input(); + } + DispatchEventResult { propagate: cx.propagate_event, default_prevented: self.default_prevented, From 8001877df2104f86ee236f9c813e2405346e716f Mon Sep 17 00:00:00 2001 From: AidanV <84053180+AidanV@users.noreply.github.com> Date: Fri, 19 Dec 2025 10:31:16 -0500 Subject: [PATCH 10/19] vim: Add `:r[ead] [name]` command (#45332) This adds the following Vim commands: - `:r[ead] [name]` - `:{range}r[ead] [name]` The most important parts of this feature are outlined [here](https://vimhelp.org/insert.txt.html#%3Ar). The only intentional difference between this and Vim is that Vim only allows `:read` (no filename) for buffers with a file attached. I am allowing it for all buffers because I think that could be useful. Release Notes: - vim: Added the [`:r[ead] [name]` Vim command](https://vimhelp.org/insert.txt.html#:read) --------- Co-authored-by: Ben Kunkle --- crates/vim/src/command.rs | 200 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 205097130d152fe255feb02a449956124586d8e6..2228c23f02beb954bdb26b2b36f078249e423d7d 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -230,6 +230,14 @@ struct VimEdit { pub filename: String, } +/// Pastes the specified file's contents. +#[derive(Clone, PartialEq, Action)] +#[action(namespace = vim, no_json, no_register)] +struct VimRead { + pub range: Option, + pub filename: String, +} + #[derive(Clone, PartialEq, Action)] #[action(namespace = vim, no_json, no_register)] struct VimNorm { @@ -643,6 +651,107 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { }); }); + Vim::action(editor, cx, |vim, action: &VimRead, window, cx| { + vim.update_editor(cx, |vim, editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let end = if let Some(range) = action.range.clone() { + let Some(multi_range) = range.buffer_range(vim, editor, window, cx).log_err() + else { + return; + }; + + match &range.start { + // inserting text above the first line uses the command ":0r {name}" + Position::Line { row: 0, offset: 0 } if range.end.is_none() => { + snapshot.clip_point(Point::new(0, 0), Bias::Right) + } + _ => snapshot.clip_point(Point::new(multi_range.end.0 + 1, 0), Bias::Right), + } + } else { + let end_row = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .range() + .end + .row; + snapshot.clip_point(Point::new(end_row + 1, 0), Bias::Right) + }; + let is_end_of_file = end == snapshot.max_point(); + let edit_range = snapshot.anchor_before(end)..snapshot.anchor_before(end); + + let mut text = if is_end_of_file { + String::from('\n') + } else { + String::new() + }; + + let mut task = None; + if action.filename.is_empty() { + text.push_str( + &editor + .buffer() + .read(cx) + .as_singleton() + .map(|buffer| buffer.read(cx).text()) + .unwrap_or_default(), + ); + } else { + if let Some(project) = editor.project().cloned() { + project.update(cx, |project, cx| { + let Some(worktree) = project.visible_worktrees(cx).next() else { + return; + }; + let path_style = worktree.read(cx).path_style(); + let Some(path) = + RelPath::new(Path::new(&action.filename), path_style).log_err() + else { + return; + }; + task = + Some(worktree.update(cx, |worktree, cx| worktree.load_file(&path, cx))); + }); + } else { + return; + } + }; + + cx.spawn_in(window, async move |editor, cx| { + if let Some(task) = task { + text.push_str( + &task + .await + .log_err() + .map(|loaded_file| loaded_file.text) + .unwrap_or_default(), + ); + } + + if !text.is_empty() && !is_end_of_file { + text.push('\n'); + } + + let _ = editor.update_in(cx, |editor, window, cx| { + editor.transact(window, cx, |editor, window, cx| { + editor.edit([(edit_range.clone(), text)], cx); + let snapshot = editor.buffer().read(cx).snapshot(cx); + editor.change_selections(Default::default(), window, cx, |s| { + let point = if is_end_of_file { + Point::new( + edit_range.start.to_point(&snapshot).row.saturating_add(1), + 0, + ) + } else { + Point::new(edit_range.start.to_point(&snapshot).row, 0) + }; + s.select_ranges([point..point]); + }) + }); + }); + }) + .detach(); + }); + }); + Vim::action(editor, cx, |vim, action: &VimNorm, window, cx| { let keystrokes = action .command @@ -1338,6 +1447,27 @@ fn generate_commands(_: &App) -> Vec { VimCommand::new(("e", "dit"), editor::actions::ReloadFile) .bang(editor::actions::ReloadFile) .filename(|_, filename| Some(VimEdit { filename }.boxed_clone())), + VimCommand::new( + ("r", "ead"), + VimRead { + range: None, + filename: "".into(), + }, + ) + .filename(|_, filename| { + Some( + VimRead { + range: None, + filename, + } + .boxed_clone(), + ) + }) + .range(|action, range| { + let mut action: VimRead = action.as_any().downcast_ref::().unwrap().clone(); + action.range.replace(range.clone()); + Some(Box::new(action)) + }), VimCommand::new(("sp", "lit"), workspace::SplitHorizontal).filename(|_, filename| { Some( VimSplit { @@ -2575,6 +2705,76 @@ mod test { assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "@@\n"); } + #[gpui::test] + async fn test_command_read(cx: &mut TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); + let path = Path::new(path!("/root/dir/other.rs")); + fs.as_fake().insert_file(path, "1\n2\n3".into()).await; + + cx.workspace(|workspace, _, cx| { + assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx); + }); + + // File without trailing newline + cx.set_state("one\ntwo\nthreeˇ", Mode::Normal); + cx.simulate_keystrokes(": r space d i r / o t h e r . r s"); + cx.simulate_keystrokes("enter"); + cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3", Mode::Normal); + + cx.set_state("oneˇ\ntwo\nthree", Mode::Normal); + cx.simulate_keystrokes(": r space d i r / o t h e r . r s"); + cx.simulate_keystrokes("enter"); + cx.assert_state("one\nˇ1\n2\n3\ntwo\nthree", Mode::Normal); + + cx.set_state("one\nˇtwo\nthree", Mode::Normal); + cx.simulate_keystrokes(": 0 r space d i r / o t h e r . r s"); + cx.simulate_keystrokes("enter"); + cx.assert_state("ˇ1\n2\n3\none\ntwo\nthree", Mode::Normal); + + cx.set_state("one\n«ˇtwo\nthree\nfour»\nfive", Mode::Visual); + cx.simulate_keystrokes(": r space d i r / o t h e r . r s"); + cx.simulate_keystrokes("enter"); + cx.run_until_parked(); + cx.assert_state("one\ntwo\nthree\nfour\nˇ1\n2\n3\nfive", Mode::Normal); + + // Empty filename + cx.set_state("oneˇ\ntwo\nthree", Mode::Normal); + cx.simulate_keystrokes(": r"); + cx.simulate_keystrokes("enter"); + cx.assert_state("one\nˇone\ntwo\nthree\ntwo\nthree", Mode::Normal); + + // File with trailing newline + fs.as_fake().insert_file(path, "1\n2\n3\n".into()).await; + cx.set_state("one\ntwo\nthreeˇ", Mode::Normal); + cx.simulate_keystrokes(": r space d i r / o t h e r . r s"); + cx.simulate_keystrokes("enter"); + cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3\n", Mode::Normal); + + cx.set_state("oneˇ\ntwo\nthree", Mode::Normal); + cx.simulate_keystrokes(": r space d i r / o t h e r . r s"); + cx.simulate_keystrokes("enter"); + cx.assert_state("one\nˇ1\n2\n3\n\ntwo\nthree", Mode::Normal); + + cx.set_state("one\n«ˇtwo\nthree\nfour»\nfive", Mode::Visual); + cx.simulate_keystrokes(": r space d i r / o t h e r . r s"); + cx.simulate_keystrokes("enter"); + cx.assert_state("one\ntwo\nthree\nfour\nˇ1\n2\n3\n\nfive", Mode::Normal); + + cx.set_state("«one\ntwo\nthreeˇ»", Mode::Visual); + cx.simulate_keystrokes(": r space d i r / o t h e r . r s"); + cx.simulate_keystrokes("enter"); + cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3\n", Mode::Normal); + + // Empty file + fs.as_fake().insert_file(path, "".into()).await; + cx.set_state("ˇone\ntwo\nthree", Mode::Normal); + cx.simulate_keystrokes(": r space d i r / o t h e r . r s"); + cx.simulate_keystrokes("enter"); + cx.assert_state("one\nˇtwo\nthree", Mode::Normal); + } + #[gpui::test] async fn test_command_quit(cx: &mut TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; From a7d43063d45d616573b26733778fb7af0fbbbf4b Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:01:48 -0300 Subject: [PATCH 11/19] workspace: Make title bar pickers render nearby the trigger when mouse-triggered (#45361) From Zed's title bar, you can click on buttons to open three modal pickers: remote projects, projects, and branches. All of these pickers use the modal layer, which by default, renders them centered on the UI. However, a UX issue we've been bothered by is that when you _click_ to open them, they show up just way too far from where your mouse likely is (nearby the trigger you just clicked). So, this PR introduces a `ModalPlacement` enum to the modal layer, so that we can pick between the "centered" and "anchored" options to render the picker. This way, we can make the pickers use anchored positioning when triggered through a mouse click and use the default centered positioning when triggered through the keybinding. One thing to note is that the anchored positioning here is not as polished as regular popovers/dropdowns, because it simply uses the x and y coordinates of the click to place the picker as opposed to using GPUI's `Corner` enum, thus making them more connected to their triggers. I chose to do it this way for now because it's a simpler and more contained change, given it wouldn't require a tighter connection at the code level between trigger and picker. But maybe we will want to do that in the near future because we can bake in some other related behaviors like automatically hiding the button trigger tooltip if the picker is open and changing its text color to communicate which button triggered the open picker. https://github.com/user-attachments/assets/30d9c26a-24de-4702-8b7d-018b397f77e1 Release Notes: - Improved the UX of title bar modal pickers (remote projects, projects, and branches) by making them open closer to the trigger when triggering them with the mouse. --- crates/title_bar/src/title_bar.rs | 272 +++++++++++++++++----------- crates/workspace/src/modal_layer.rs | 77 ++++++-- crates/workspace/src/workspace.rs | 17 +- 3 files changed, 240 insertions(+), 126 deletions(-) diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index d7759b0df8019eed2ad59b73bcaffaa3ffcfb866..9b75d35eccafa3c30f23329d9c0ee890ed2b2405 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -166,11 +166,11 @@ impl Render for TitleBar { .when(title_bar_settings.show_project_items, |title_bar| { title_bar .children(self.render_restricted_mode(cx)) - .children(self.render_project_host(cx)) - .child(self.render_project_name(cx)) + .children(self.render_project_host(window, cx)) + .child(self.render_project_name(window, cx)) }) .when(title_bar_settings.show_branch_name, |title_bar| { - title_bar.children(self.render_project_repo(cx)) + title_bar.children(self.render_project_repo(window, cx)) }) }) }) @@ -350,7 +350,14 @@ impl TitleBar { .next() } - fn render_remote_project_connection(&self, cx: &mut Context) -> Option { + fn render_remote_project_connection( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let workspace = self.workspace.clone(); + let is_picker_open = self.is_picker_open(window, cx); + let options = self.project.read(cx).remote_connection_options(cx)?; let host: SharedString = options.display_name().into(); @@ -395,7 +402,7 @@ impl TitleBar { let meta = SharedString::from(meta); Some( - ButtonLike::new("ssh-server-icon") + ButtonLike::new("remote_project") .child( h_flex() .gap_2() @@ -410,26 +417,35 @@ impl TitleBar { ) .child(Label::new(nickname).size(LabelSize::Small).truncate()), ) - .tooltip(move |_window, cx| { - Tooltip::with_meta( - tooltip_title, - Some(&OpenRemote { - from_existing_connection: false, - create_new_window: false, - }), - meta.clone(), - cx, - ) + .when(!is_picker_open, |this| { + this.tooltip(move |_window, cx| { + Tooltip::with_meta( + tooltip_title, + Some(&OpenRemote { + from_existing_connection: false, + create_new_window: false, + }), + meta.clone(), + cx, + ) + }) }) - .on_click(|_, window, cx| { - window.dispatch_action( - OpenRemote { - from_existing_connection: false, - create_new_window: false, - } - .boxed_clone(), - cx, - ); + .on_click(move |event, window, cx| { + let position = event.position(); + let _ = workspace.update(cx, |this, cx| { + this.set_next_modal_placement(workspace::ModalPlacement::Anchored { + position, + }); + + window.dispatch_action( + OpenRemote { + from_existing_connection: false, + create_new_window: false, + } + .boxed_clone(), + cx, + ); + }); }) .into_any_element(), ) @@ -481,9 +497,13 @@ impl TitleBar { } } - pub fn render_project_host(&self, cx: &mut Context) -> Option { + pub fn render_project_host( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option { if self.project.read(cx).is_via_remote_server() { - return self.render_remote_project_connection(cx); + return self.render_remote_project_connection(window, cx); } if self.project.read(cx).is_disconnected(cx) { @@ -491,7 +511,6 @@ impl TitleBar { Button::new("disconnected", "Disconnected") .disabled(true) .color(Color::Disabled) - .style(ButtonStyle::Subtle) .label_size(LabelSize::Small) .into_any_element(), ); @@ -504,15 +523,19 @@ impl TitleBar { .read(cx) .participant_indices() .get(&host_user.id)?; + Some( Button::new("project_owner_trigger", host_user.github_login.clone()) .color(Color::Player(participant_index.0)) - .style(ButtonStyle::Subtle) .label_size(LabelSize::Small) - .tooltip(Tooltip::text(format!( - "{} is sharing this project. Click to follow.", - host_user.github_login - ))) + .tooltip(move |_, cx| { + let tooltip_title = format!( + "{} is sharing this project. Click to follow.", + host_user.github_login + ); + + Tooltip::with_meta(tooltip_title, None, "Click to Follow", cx) + }) .on_click({ let host_peer_id = host.peer_id; cx.listener(move |this, _, window, cx| { @@ -527,7 +550,14 @@ impl TitleBar { ) } - pub fn render_project_name(&self, cx: &mut Context) -> impl IntoElement { + pub fn render_project_name( + &self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let workspace = self.workspace.clone(); + let is_picker_open = self.is_picker_open(window, cx); + let name = self.project_name(cx); let is_project_selected = name.is_some(); let name = if let Some(name) = name { @@ -537,19 +567,25 @@ impl TitleBar { }; Button::new("project_name_trigger", name) - .when(!is_project_selected, |b| b.color(Color::Muted)) - .style(ButtonStyle::Subtle) .label_size(LabelSize::Small) - .tooltip(move |_window, cx| { - Tooltip::for_action( - "Recent Projects", - &zed_actions::OpenRecent { - create_new_window: false, - }, - cx, - ) + .when(!is_project_selected, |s| s.color(Color::Muted)) + .when(!is_picker_open, |this| { + this.tooltip(move |_window, cx| { + Tooltip::for_action( + "Recent Projects", + &zed_actions::OpenRecent { + create_new_window: false, + }, + cx, + ) + }) }) - .on_click(cx.listener(move |_, _, window, cx| { + .on_click(move |event, window, cx| { + let position = event.position(); + let _ = workspace.update(cx, |this, _cx| { + this.set_next_modal_placement(workspace::ModalPlacement::Anchored { position }) + }); + window.dispatch_action( OpenRecent { create_new_window: false, @@ -557,84 +593,102 @@ impl TitleBar { .boxed_clone(), cx, ); - })) + }) } - pub fn render_project_repo(&self, cx: &mut Context) -> Option { - let settings = TitleBarSettings::get_global(cx); + pub fn render_project_repo( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option { let repository = self.project.read(cx).active_repository(cx)?; let repository_count = self.project.read(cx).repositories(cx).len(); let workspace = self.workspace.upgrade()?; - let repo = repository.read(cx); - let branch_name = repo - .branch - .as_ref() - .map(|branch| branch.name()) - .map(|name| util::truncate_and_trailoff(name, MAX_BRANCH_NAME_LENGTH)) - .or_else(|| { - repo.head_commit.as_ref().map(|commit| { - commit - .sha - .chars() - .take(MAX_SHORT_SHA_LENGTH) - .collect::() - }) - })?; - let project_name = self.project_name(cx); - let repo_name = repo - .work_directory_abs_path - .file_name() - .and_then(|name| name.to_str()) - .map(SharedString::new); - let show_repo_name = - repository_count > 1 && repo.branch.is_some() && repo_name != project_name; - let branch_name = if let Some(repo_name) = repo_name.filter(|_| show_repo_name) { - format!("{repo_name}/{branch_name}") - } else { - branch_name + + let (branch_name, icon_info) = { + let repo = repository.read(cx); + let branch_name = repo + .branch + .as_ref() + .map(|branch| branch.name()) + .map(|name| util::truncate_and_trailoff(name, MAX_BRANCH_NAME_LENGTH)) + .or_else(|| { + repo.head_commit.as_ref().map(|commit| { + commit + .sha + .chars() + .take(MAX_SHORT_SHA_LENGTH) + .collect::() + }) + }); + + let branch_name = branch_name?; + + let project_name = self.project_name(cx); + let repo_name = repo + .work_directory_abs_path + .file_name() + .and_then(|name| name.to_str()) + .map(SharedString::new); + let show_repo_name = + repository_count > 1 && repo.branch.is_some() && repo_name != project_name; + let branch_name = if let Some(repo_name) = repo_name.filter(|_| show_repo_name) { + format!("{repo_name}/{branch_name}") + } else { + branch_name + }; + + let status = repo.status_summary(); + let tracked = status.index + status.worktree; + let icon_info = if status.conflict > 0 { + (IconName::Warning, Color::VersionControlConflict) + } else if tracked.modified > 0 { + (IconName::SquareDot, Color::VersionControlModified) + } else if tracked.added > 0 || status.untracked > 0 { + (IconName::SquarePlus, Color::VersionControlAdded) + } else if tracked.deleted > 0 { + (IconName::SquareMinus, Color::VersionControlDeleted) + } else { + (IconName::GitBranch, Color::Muted) + }; + + (branch_name, icon_info) }; + let is_picker_open = self.is_picker_open(window, cx); + let settings = TitleBarSettings::get_global(cx); + Some( Button::new("project_branch_trigger", branch_name) - .color(Color::Muted) - .style(ButtonStyle::Subtle) .label_size(LabelSize::Small) - .tooltip(move |_window, cx| { - Tooltip::with_meta( - "Recent Branches", - Some(&zed_actions::git::Branch), - "Local branches only", - cx, - ) - }) - .on_click(move |_, window, cx| { - let _ = workspace.update(cx, |this, cx| { - window.focus(&this.active_pane().focus_handle(cx), cx); - window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx); - }); + .color(Color::Muted) + .when(!is_picker_open, |this| { + this.tooltip(move |_window, cx| { + Tooltip::with_meta( + "Recent Branches", + Some(&zed_actions::git::Branch), + "Local branches only", + cx, + ) + }) }) .when(settings.show_branch_icon, |branch_button| { - let (icon, icon_color) = { - let status = repo.status_summary(); - let tracked = status.index + status.worktree; - if status.conflict > 0 { - (IconName::Warning, Color::VersionControlConflict) - } else if tracked.modified > 0 { - (IconName::SquareDot, Color::VersionControlModified) - } else if tracked.added > 0 || status.untracked > 0 { - (IconName::SquarePlus, Color::VersionControlAdded) - } else if tracked.deleted > 0 { - (IconName::SquareMinus, Color::VersionControlDeleted) - } else { - (IconName::GitBranch, Color::Muted) - } - }; - + let (icon, icon_color) = icon_info; branch_button .icon(icon) .icon_position(IconPosition::Start) .icon_color(icon_color) .icon_size(IconSize::Indicator) + }) + .on_click(move |event, window, cx| { + let position = event.position(); + let _ = workspace.update(cx, |this, cx| { + this.set_next_modal_placement(workspace::ModalPlacement::Anchored { + position, + }); + window.focus(&this.active_pane().focus_handle(cx), cx); + window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx); + }); }), ) } @@ -726,7 +780,7 @@ impl TitleBar { pub fn render_sign_in_button(&mut self, _: &mut Context) -> Button { let client = self.client.clone(); - Button::new("sign_in", "Sign in") + Button::new("sign_in", "Sign In") .label_size(LabelSize::Small) .on_click(move |_, window, cx| { let client = client.clone(); @@ -848,4 +902,10 @@ impl TitleBar { }) .anchor(gpui::Corner::TopRight) } + + fn is_picker_open(&self, window: &mut Window, cx: &mut Context) -> bool { + self.workspace + .update(cx, |workspace, cx| workspace.has_active_modal(window, cx)) + .unwrap_or(false) + } } diff --git a/crates/workspace/src/modal_layer.rs b/crates/workspace/src/modal_layer.rs index 58667e7ffa8ad4fe5a22d293e4fc4aa71015a3bd..4087e1a398ac2b89257fea6b4dce53278d0872a8 100644 --- a/crates/workspace/src/modal_layer.rs +++ b/crates/workspace/src/modal_layer.rs @@ -1,9 +1,18 @@ use gpui::{ AnyView, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable as _, ManagedView, - MouseButton, Subscription, + MouseButton, Pixels, Point, Subscription, }; use ui::prelude::*; +#[derive(Debug, Clone, Copy, Default)] +pub enum ModalPlacement { + #[default] + Centered, + Anchored { + position: Point, + }, +} + #[derive(Debug)] pub enum DismissDecision { Dismiss(bool), @@ -58,6 +67,7 @@ pub struct ActiveModal { _subscriptions: [Subscription; 2], previous_focus_handle: Option, focus_handle: FocusHandle, + placement: ModalPlacement, } pub struct ModalLayer { @@ -87,6 +97,19 @@ impl ModalLayer { where V: ModalView, B: FnOnce(&mut Window, &mut Context) -> V, + { + self.toggle_modal_with_placement(window, cx, ModalPlacement::Centered, build_view); + } + + pub fn toggle_modal_with_placement( + &mut self, + window: &mut Window, + cx: &mut Context, + placement: ModalPlacement, + build_view: B, + ) where + V: ModalView, + B: FnOnce(&mut Window, &mut Context) -> V, { if let Some(active_modal) = &self.active_modal { let is_close = active_modal.modal.view().downcast::().is_ok(); @@ -96,12 +119,17 @@ impl ModalLayer { } } let new_modal = cx.new(|cx| build_view(window, cx)); - self.show_modal(new_modal, window, cx); + self.show_modal(new_modal, placement, window, cx); cx.emit(ModalOpenedEvent); } - fn show_modal(&mut self, new_modal: Entity, window: &mut Window, cx: &mut Context) - where + fn show_modal( + &mut self, + new_modal: Entity, + placement: ModalPlacement, + window: &mut Window, + cx: &mut Context, + ) where V: ModalView, { let focus_handle = cx.focus_handle(); @@ -123,6 +151,7 @@ impl ModalLayer { ], previous_focus_handle: window.focused(cx), focus_handle, + placement, }); cx.defer_in(window, move |_, window, cx| { window.focus(&new_modal.focus_handle(cx), cx); @@ -183,6 +212,30 @@ impl Render for ModalLayer { return active_modal.modal.view().into_any_element(); } + let content = h_flex() + .occlude() + .child(active_modal.modal.view()) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }); + + let positioned = match active_modal.placement { + ModalPlacement::Centered => v_flex() + .h(px(0.0)) + .top_20() + .items_center() + .track_focus(&active_modal.focus_handle) + .child(content) + .into_any_element(), + ModalPlacement::Anchored { position } => div() + .absolute() + .left(position.x) + .top(position.y - px(20.)) + .track_focus(&active_modal.focus_handle) + .child(content) + .into_any_element(), + }; + div() .absolute() .size_full() @@ -199,21 +252,7 @@ impl Render for ModalLayer { this.hide_modal(window, cx); }), ) - .child( - v_flex() - .h(px(0.0)) - .top_20() - .items_center() - .track_focus(&active_modal.focus_handle) - .child( - h_flex() - .occlude() - .child(active_modal.modal.view()) - .on_mouse_down(MouseButton::Left, |_, _, cx| { - cx.stop_propagation(); - }), - ), - ) + .child(positioned) .into_any_element() } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 53b0cc0623fa4b3ce7de5f1d8e3fd2262210a09a..c88386281e73b243dbddd6cb00c80fb26595409e 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1204,6 +1204,7 @@ pub struct Workspace { last_open_dock_positions: Vec, removing: bool, utility_panes: UtilityPaneState, + next_modal_placement: Option, } impl EventEmitter for Workspace {} @@ -1620,6 +1621,7 @@ impl Workspace { last_open_dock_positions: Vec::new(), removing: false, utility_panes: UtilityPaneState::default(), + next_modal_placement: None, } } @@ -6326,12 +6328,25 @@ impl Workspace { self.modal_layer.read(cx).active_modal() } + pub fn is_modal_open(&self, cx: &App) -> bool { + self.modal_layer.read(cx).active_modal::().is_some() + } + + pub fn set_next_modal_placement(&mut self, placement: ModalPlacement) { + self.next_modal_placement = Some(placement); + } + + fn take_next_modal_placement(&mut self) -> ModalPlacement { + self.next_modal_placement.take().unwrap_or_default() + } + pub fn toggle_modal(&mut self, window: &mut Window, cx: &mut App, build: B) where B: FnOnce(&mut Window, &mut Context) -> V, { + let placement = self.take_next_modal_placement(); self.modal_layer.update(cx, |modal_layer, cx| { - modal_layer.toggle_modal(window, cx, build) + modal_layer.toggle_modal_with_placement(window, cx, placement, build) }) } From ea34cc5324c12ee44d7128f15f9bc5de42d3f358 Mon Sep 17 00:00:00 2001 From: feeiyu <158308373+feeiyu@users.noreply.github.com> Date: Sat, 20 Dec 2025 00:06:16 +0800 Subject: [PATCH 12/19] Fix terminal doesn't switch to project directory when opening remote project on Windows (#45328) Closes #45253 Release Notes: - Fixed terminal doesn't switch to project directory when opening remote project on Windows --- crates/remote/src/transport/ssh.rs | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/crates/remote/src/transport/ssh.rs b/crates/remote/src/transport/ssh.rs index 6c8eb49c1c2158322a275e064162b53e2f5f3d5e..d13e1c4934947e39b08e05eb32e2787548e621e1 100644 --- a/crates/remote/src/transport/ssh.rs +++ b/crates/remote/src/transport/ssh.rs @@ -32,8 +32,7 @@ use tempfile::TempDir; use util::{ paths::{PathStyle, RemotePathBuf}, rel_path::RelPath, - shell::{Shell, ShellKind}, - shell_builder::ShellBuilder, + shell::ShellKind, }; pub(crate) struct SshRemoteConnection { @@ -1544,8 +1543,6 @@ fn build_command( } else { write!(exec, "{ssh_shell} -l")?; }; - let (command, command_args) = ShellBuilder::new(&Shell::Program(ssh_shell.to_owned()), false) - .build(Some(exec.clone()), &[]); let mut args = Vec::new(); args.extend(ssh_args); @@ -1556,8 +1553,7 @@ fn build_command( } args.push("-t".into()); - args.push(command); - args.extend(command_args); + args.push(exec); Ok(CommandTemplate { program: "ssh".into(), @@ -1597,9 +1593,6 @@ mod tests { "-p", "2222", "-t", - "/bin/fish", - "-i", - "-c", "cd \"$HOME/work\" && exec env INPUT_VA=val remote_program arg1 arg2" ] ); @@ -1632,9 +1625,6 @@ mod tests { "-L", "1:foo:2", "-t", - "/bin/fish", - "-i", - "-c", "cd && exec env INPUT_VA=val /bin/fish -l" ] ); From a7e07010e58bc53bcc8a33adbb0e5e31d251c432 Mon Sep 17 00:00:00 2001 From: "Raduan A." <36044389+0xRaduan@users.noreply.github.com> Date: Fri, 19 Dec 2025 17:14:02 +0100 Subject: [PATCH 13/19] editor: Add automatic markdown list continuation on newline and indent on tab (#42800) Closes #5089 Release notes: - Markdown lists now continue automatically when you press Enter (unordered, ordered, and task lists). This can be configured with `extend_list_on_newline` (default: true). - You can now indent list markers with Tab to quickly create nested lists. This can be configured with `indent_list_on_tab` (default: true). --------- Co-authored-by: Claude Co-authored-by: Smit Barmase --- assets/settings/default.json | 4 + crates/editor/src/editor.rs | 459 +++++++++++--- crates/editor/src/editor_tests.rs | 588 ++++++++++++++++-- crates/language/src/language.rs | 45 ++ crates/language/src/language_settings.rs | 6 + crates/languages/src/markdown/config.toml | 3 + .../settings/src/settings_content/language.rs | 8 + crates/settings/src/vscode_import.rs | 2 + docs/src/configuring-zed.md | 20 + docs/src/languages/markdown.md | 34 + 10 files changed, 1021 insertions(+), 148 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 154fe2d6e34e6573e95e7ffedbb46df8bbf10634..746ccb5986d0fd1d5ef11df525303e344a7393d2 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1178,6 +1178,10 @@ "remove_trailing_whitespace_on_save": true, // Whether to start a new line with a comment when a previous line is a comment as well. "extend_comment_on_newline": true, + // Whether to continue markdown lists when pressing enter. + "extend_list_on_newline": true, + // Whether to indent list items when pressing tab after a list marker. + "indent_list_on_tab": true, // Removes any lines containing only whitespace at the end of the file and // ensures just one newline at the end. "ensure_final_newline_on_save": true, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 8560705802264dad55b87dbf21e1f9aa7625edf8..6e4744335b8e9fba50a6c2c8b241607b0e05d276 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -163,6 +163,7 @@ use project::{ project_settings::{DiagnosticSeverity, GoToDiagnosticSeverityFilter, ProjectSettings}, }; use rand::seq::SliceRandom; +use regex::Regex; use rpc::{ErrorCode, ErrorExt, proto::PeerId}; use scroll::{Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager}; use selections_collection::{MutableSelectionsCollection, SelectionsCollection}; @@ -4787,82 +4788,146 @@ impl Editor { let end = selection.end; let selection_is_empty = start == end; let language_scope = buffer.language_scope_at(start); - let (comment_delimiter, doc_delimiter, newline_formatting) = - if let Some(language) = &language_scope { - let mut newline_formatting = - NewlineFormatting::new(&buffer, start..end, language); - - // Comment extension on newline is allowed only for cursor selections - let comment_delimiter = maybe!({ - if !selection_is_empty { - return None; - } + let (delimiter, newline_config) = if let Some(language) = &language_scope { + let needs_extra_newline = NewlineConfig::insert_extra_newline_brackets( + &buffer, + start..end, + language, + ) + || NewlineConfig::insert_extra_newline_tree_sitter( + &buffer, + start..end, + ); - if !multi_buffer.language_settings(cx).extend_comment_on_newline - { - return None; - } + let mut newline_config = NewlineConfig::Newline { + additional_indent: IndentSize::spaces(0), + extra_line_additional_indent: if needs_extra_newline { + Some(IndentSize::spaces(0)) + } else { + None + }, + prevent_auto_indent: false, + }; - return comment_delimiter_for_newline( - &start_point, - &buffer, - language, - ); - }); + let comment_delimiter = maybe!({ + if !selection_is_empty { + return None; + } - let doc_delimiter = maybe!({ - if !selection_is_empty { - return None; - } + if !multi_buffer.language_settings(cx).extend_comment_on_newline { + return None; + } - if !multi_buffer.language_settings(cx).extend_comment_on_newline - { - return None; - } + return comment_delimiter_for_newline( + &start_point, + &buffer, + language, + ); + }); - return documentation_delimiter_for_newline( - &start_point, - &buffer, - language, - &mut newline_formatting, - ); - }); + let doc_delimiter = maybe!({ + if !selection_is_empty { + return None; + } - (comment_delimiter, doc_delimiter, newline_formatting) - } else { - (None, None, NewlineFormatting::default()) - }; + if !multi_buffer.language_settings(cx).extend_comment_on_newline { + return None; + } - let prevent_auto_indent = doc_delimiter.is_some(); - let delimiter = comment_delimiter.or(doc_delimiter); + return documentation_delimiter_for_newline( + &start_point, + &buffer, + language, + &mut newline_config, + ); + }); - let capacity_for_delimiter = - delimiter.as_deref().map(str::len).unwrap_or_default(); - let mut new_text = String::with_capacity( - 1 + capacity_for_delimiter - + existing_indent.len as usize - + newline_formatting.indent_on_newline.len as usize - + newline_formatting.indent_on_extra_newline.len as usize, - ); - new_text.push('\n'); - new_text.extend(existing_indent.chars()); - new_text.extend(newline_formatting.indent_on_newline.chars()); + let list_delimiter = maybe!({ + if !selection_is_empty { + return None; + } - if let Some(delimiter) = &delimiter { - new_text.push_str(delimiter); - } + if !multi_buffer.language_settings(cx).extend_list_on_newline { + return None; + } - if newline_formatting.insert_extra_newline { - new_text.push('\n'); - new_text.extend(existing_indent.chars()); - new_text.extend(newline_formatting.indent_on_extra_newline.chars()); - } + return list_delimiter_for_newline( + &start_point, + &buffer, + language, + &mut newline_config, + ); + }); + + ( + comment_delimiter.or(doc_delimiter).or(list_delimiter), + newline_config, + ) + } else { + ( + None, + NewlineConfig::Newline { + additional_indent: IndentSize::spaces(0), + extra_line_additional_indent: None, + prevent_auto_indent: false, + }, + ) + }; + + let (edit_start, new_text, prevent_auto_indent) = match &newline_config { + NewlineConfig::ClearCurrentLine => { + let row_start = + buffer.point_to_offset(Point::new(start_point.row, 0)); + (row_start, String::new(), false) + } + NewlineConfig::UnindentCurrentLine { continuation } => { + let row_start = + buffer.point_to_offset(Point::new(start_point.row, 0)); + let tab_size = buffer.language_settings_at(start, cx).tab_size; + let tab_size_indent = IndentSize::spaces(tab_size.get()); + let reduced_indent = + existing_indent.with_delta(Ordering::Less, tab_size_indent); + let mut new_text = String::new(); + new_text.extend(reduced_indent.chars()); + new_text.push_str(continuation); + (row_start, new_text, true) + } + NewlineConfig::Newline { + additional_indent, + extra_line_additional_indent, + prevent_auto_indent, + } => { + let capacity_for_delimiter = + delimiter.as_deref().map(str::len).unwrap_or_default(); + let extra_line_len = extra_line_additional_indent + .map(|i| 1 + existing_indent.len as usize + i.len as usize) + .unwrap_or(0); + let mut new_text = String::with_capacity( + 1 + capacity_for_delimiter + + existing_indent.len as usize + + additional_indent.len as usize + + extra_line_len, + ); + new_text.push('\n'); + new_text.extend(existing_indent.chars()); + new_text.extend(additional_indent.chars()); + if let Some(delimiter) = &delimiter { + new_text.push_str(delimiter); + } + if let Some(extra_indent) = extra_line_additional_indent { + new_text.push('\n'); + new_text.extend(existing_indent.chars()); + new_text.extend(extra_indent.chars()); + } + (start, new_text, *prevent_auto_indent) + } + }; let anchor = buffer.anchor_after(end); let new_selection = selection.map(|_| anchor); ( - ((start..end, new_text), prevent_auto_indent), - (newline_formatting.insert_extra_newline, new_selection), + ((edit_start..end, new_text), prevent_auto_indent), + (newline_config.has_extra_line(), new_selection), ) }) .unzip() @@ -10387,6 +10452,22 @@ impl Editor { } prev_edited_row = selection.end.row; + // If cursor is after a list prefix, make selection non-empty to trigger line indent + if selection.is_empty() { + let cursor = selection.head(); + let settings = buffer.language_settings_at(cursor, cx); + if settings.indent_list_on_tab { + if let Some(language) = snapshot.language_scope_at(Point::new(cursor.row, 0)) { + if is_list_prefix_row(MultiBufferRow(cursor.row), &snapshot, &language) { + row_delta = Self::indent_selection( + buffer, &snapshot, selection, &mut edits, row_delta, cx, + ); + continue; + } + } + } + } + // If the selection is non-empty, then increase the indentation of the selected lines. if !selection.is_empty() { row_delta = @@ -23355,7 +23436,7 @@ fn documentation_delimiter_for_newline( start_point: &Point, buffer: &MultiBufferSnapshot, language: &LanguageScope, - newline_formatting: &mut NewlineFormatting, + newline_config: &mut NewlineConfig, ) -> Option> { let BlockCommentConfig { start: start_tag, @@ -23407,6 +23488,9 @@ fn documentation_delimiter_for_newline( } }; + let mut needs_extra_line = false; + let mut extra_line_additional_indent = IndentSize::spaces(0); + let cursor_is_before_end_tag_if_exists = { let mut char_position = 0u32; let mut end_tag_offset = None; @@ -23424,11 +23508,11 @@ fn documentation_delimiter_for_newline( let cursor_is_before_end_tag = column <= end_tag_offset; if cursor_is_after_start_tag { if cursor_is_before_end_tag { - newline_formatting.insert_extra_newline = true; + needs_extra_line = true; } let cursor_is_at_start_of_end_tag = column == end_tag_offset; if cursor_is_at_start_of_end_tag { - newline_formatting.indent_on_extra_newline.len = *len; + extra_line_additional_indent.len = *len; } } cursor_is_before_end_tag @@ -23440,39 +23524,240 @@ fn documentation_delimiter_for_newline( if (cursor_is_after_start_tag || cursor_is_after_delimiter) && cursor_is_before_end_tag_if_exists { - if cursor_is_after_start_tag { - newline_formatting.indent_on_newline.len = *len; - } + let additional_indent = if cursor_is_after_start_tag { + IndentSize::spaces(*len) + } else { + IndentSize::spaces(0) + }; + + *newline_config = NewlineConfig::Newline { + additional_indent, + extra_line_additional_indent: if needs_extra_line { + Some(extra_line_additional_indent) + } else { + None + }, + prevent_auto_indent: true, + }; Some(delimiter.clone()) } else { None } } -#[derive(Debug, Default)] -struct NewlineFormatting { - insert_extra_newline: bool, - indent_on_newline: IndentSize, - indent_on_extra_newline: IndentSize, +const ORDERED_LIST_MAX_MARKER_LEN: usize = 16; + +fn list_delimiter_for_newline( + start_point: &Point, + buffer: &MultiBufferSnapshot, + language: &LanguageScope, + newline_config: &mut NewlineConfig, +) -> Option> { + let (snapshot, range) = buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?; + + let num_of_whitespaces = snapshot + .chars_for_range(range.clone()) + .take_while(|c| c.is_whitespace()) + .count(); + + let task_list_entries: Vec<_> = language + .task_list() + .into_iter() + .flat_map(|config| { + config + .prefixes + .iter() + .map(|prefix| (prefix.as_ref(), config.continuation.as_ref())) + }) + .collect(); + let unordered_list_entries: Vec<_> = language + .unordered_list() + .iter() + .map(|marker| (marker.as_ref(), marker.as_ref())) + .collect(); + + let all_entries: Vec<_> = task_list_entries + .into_iter() + .chain(unordered_list_entries) + .collect(); + + if let Some(max_prefix_len) = all_entries.iter().map(|(p, _)| p.len()).max() { + let candidate: String = snapshot + .chars_for_range(range.clone()) + .skip(num_of_whitespaces) + .take(max_prefix_len) + .collect(); + + if let Some((prefix, continuation)) = all_entries + .iter() + .filter(|(prefix, _)| candidate.starts_with(*prefix)) + .max_by_key(|(prefix, _)| prefix.len()) + { + let end_of_prefix = num_of_whitespaces + prefix.len(); + let cursor_is_after_prefix = end_of_prefix <= start_point.column as usize; + let has_content_after_marker = snapshot + .chars_for_range(range) + .skip(end_of_prefix) + .any(|c| !c.is_whitespace()); + + if has_content_after_marker && cursor_is_after_prefix { + return Some((*continuation).into()); + } + + if start_point.column as usize == end_of_prefix { + if num_of_whitespaces == 0 { + *newline_config = NewlineConfig::ClearCurrentLine; + } else { + *newline_config = NewlineConfig::UnindentCurrentLine { + continuation: (*continuation).into(), + }; + } + } + + return None; + } + } + + let candidate: String = snapshot + .chars_for_range(range.clone()) + .skip(num_of_whitespaces) + .take(ORDERED_LIST_MAX_MARKER_LEN) + .collect(); + + for ordered_config in language.ordered_list() { + let regex = match Regex::new(&ordered_config.pattern) { + Ok(r) => r, + Err(_) => continue, + }; + + if let Some(captures) = regex.captures(&candidate) { + let full_match = captures.get(0)?; + let marker_len = full_match.len(); + let end_of_prefix = num_of_whitespaces + marker_len; + let cursor_is_after_prefix = end_of_prefix <= start_point.column as usize; + + let has_content_after_marker = snapshot + .chars_for_range(range) + .skip(end_of_prefix) + .any(|c| !c.is_whitespace()); + + if has_content_after_marker && cursor_is_after_prefix { + let number: u32 = captures.get(1)?.as_str().parse().ok()?; + let continuation = ordered_config + .format + .replace("{1}", &(number + 1).to_string()); + return Some(continuation.into()); + } + + if start_point.column as usize == end_of_prefix { + let continuation = ordered_config.format.replace("{1}", "1"); + if num_of_whitespaces == 0 { + *newline_config = NewlineConfig::ClearCurrentLine; + } else { + *newline_config = NewlineConfig::UnindentCurrentLine { + continuation: continuation.into(), + }; + } + } + + return None; + } + } + + None } -impl NewlineFormatting { - fn new( - buffer: &MultiBufferSnapshot, - range: Range, - language: &LanguageScope, - ) -> Self { - Self { - insert_extra_newline: Self::insert_extra_newline_brackets( - buffer, - range.clone(), - language, - ) || Self::insert_extra_newline_tree_sitter(buffer, range), - indent_on_newline: IndentSize::spaces(0), - indent_on_extra_newline: IndentSize::spaces(0), +fn is_list_prefix_row( + row: MultiBufferRow, + buffer: &MultiBufferSnapshot, + language: &LanguageScope, +) -> bool { + let Some((snapshot, range)) = buffer.buffer_line_for_row(row) else { + return false; + }; + + let num_of_whitespaces = snapshot + .chars_for_range(range.clone()) + .take_while(|c| c.is_whitespace()) + .count(); + + let task_list_prefixes: Vec<_> = language + .task_list() + .into_iter() + .flat_map(|config| { + config + .prefixes + .iter() + .map(|p| p.as_ref()) + .collect::>() + }) + .collect(); + let unordered_list_markers: Vec<_> = language + .unordered_list() + .iter() + .map(|marker| marker.as_ref()) + .collect(); + let all_prefixes: Vec<_> = task_list_prefixes + .into_iter() + .chain(unordered_list_markers) + .collect(); + if let Some(max_prefix_len) = all_prefixes.iter().map(|p| p.len()).max() { + let candidate: String = snapshot + .chars_for_range(range.clone()) + .skip(num_of_whitespaces) + .take(max_prefix_len) + .collect(); + if all_prefixes + .iter() + .any(|prefix| candidate.starts_with(*prefix)) + { + return true; } } + let ordered_list_candidate: String = snapshot + .chars_for_range(range) + .skip(num_of_whitespaces) + .take(ORDERED_LIST_MAX_MARKER_LEN) + .collect(); + for ordered_config in language.ordered_list() { + let regex = match Regex::new(&ordered_config.pattern) { + Ok(r) => r, + Err(_) => continue, + }; + if let Some(captures) = regex.captures(&ordered_list_candidate) { + return captures.get(0).is_some(); + } + } + + false +} + +#[derive(Debug)] +enum NewlineConfig { + /// Insert newline with optional additional indent and optional extra blank line + Newline { + additional_indent: IndentSize, + extra_line_additional_indent: Option, + prevent_auto_indent: bool, + }, + /// Clear the current line + ClearCurrentLine, + /// Unindent the current line and add continuation + UnindentCurrentLine { continuation: Arc }, +} + +impl NewlineConfig { + fn has_extra_line(&self) -> bool { + matches!( + self, + Self::Newline { + extra_line_additional_indent: Some(_), + .. + } + ) + } + fn insert_extra_newline_brackets( buffer: &MultiBufferSnapshot, range: Range, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index c0112c5eda406c9cb3b3b9d004d20853b710f6e1..87674d8c507b1c294779b1f9ddba458320fc7671 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -28021,7 +28021,7 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) { " }); - // Case 2: Test adding new line after nested list preserves indent of previous line + // Case 2: Test adding new line after nested list continues the list with unchecked task cx.set_state(&indoc! {" - [ ] Item 1 - [ ] Item 1.a @@ -28038,32 +28038,12 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) { - [x] Item 2 - [x] Item 2.a - [x] Item 2.b - ˇ" + - [ ] ˇ" }); - // Case 3: Test adding a new nested list item preserves indent - cx.set_state(&indoc! {" - - [ ] Item 1 - - [ ] Item 1.a - - [x] Item 2 - - [x] Item 2.a - - [x] Item 2.b - ˇ" - }); - cx.update_editor(|editor, window, cx| { - editor.handle_input("-", window, cx); - }); - cx.run_until_parked(); - cx.assert_editor_state(indoc! {" - - [ ] Item 1 - - [ ] Item 1.a - - [x] Item 2 - - [x] Item 2.a - - [x] Item 2.b - -ˇ" - }); + // Case 3: Test adding content to continued list item cx.update_editor(|editor, window, cx| { - editor.handle_input(" [x] Item 2.c", window, cx); + editor.handle_input("Item 2.c", window, cx); }); cx.run_until_parked(); cx.assert_editor_state(indoc! {" @@ -28072,10 +28052,10 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) { - [x] Item 2 - [x] Item 2.a - [x] Item 2.b - - [x] Item 2.cˇ" + - [ ] Item 2.cˇ" }); - // Case 4: Test adding new line after nested ordered list preserves indent of previous line + // Case 4: Test adding new line after nested ordered list continues with next number cx.set_state(indoc! {" 1. Item 1 1. Item 1.a @@ -28092,44 +28072,12 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) { 2. Item 2 1. Item 2.a 2. Item 2.b - ˇ" + 3. ˇ" }); - // Case 5: Adding new ordered list item preserves indent - cx.set_state(indoc! {" - 1. Item 1 - 1. Item 1.a - 2. Item 2 - 1. Item 2.a - 2. Item 2.b - ˇ" - }); - cx.update_editor(|editor, window, cx| { - editor.handle_input("3", window, cx); - }); - cx.run_until_parked(); - cx.assert_editor_state(indoc! {" - 1. Item 1 - 1. Item 1.a - 2. Item 2 - 1. Item 2.a - 2. Item 2.b - 3ˇ" - }); - cx.update_editor(|editor, window, cx| { - editor.handle_input(".", window, cx); - }); - cx.run_until_parked(); - cx.assert_editor_state(indoc! {" - 1. Item 1 - 1. Item 1.a - 2. Item 2 - 1. Item 2.a - 2. Item 2.b - 3.ˇ" - }); + // Case 5: Adding content to continued ordered list item cx.update_editor(|editor, window, cx| { - editor.handle_input(" Item 2.c", window, cx); + editor.handle_input("Item 2.c", window, cx); }); cx.run_until_parked(); cx.assert_editor_state(indoc! {" @@ -29497,6 +29445,524 @@ async fn test_find_references_single_case(cx: &mut TestAppContext) { cx.assert_editor_state(after); } +#[gpui::test] +async fn test_newline_task_list_continuation(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = Some(2.try_into().unwrap()); + }); + + let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Case 1: Adding newline after (whitespace + prefix + any non-whitespace) adds marker + cx.set_state(indoc! {" + - [ ] taskˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ ] task + - [ ] ˇ + "}); + + // Case 2: Works with checked task items too + cx.set_state(indoc! {" + - [x] completed taskˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [x] completed task + - [ ] ˇ + "}); + + // Case 3: Cursor position doesn't matter - content after marker is what counts + cx.set_state(indoc! {" + - [ ] taˇsk + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ ] ta + - [ ] ˇsk + "}); + + // Case 4: Adding newline after (whitespace + prefix + some whitespace) does NOT add marker + cx.set_state(indoc! {" + - [ ] ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state( + indoc! {" + - [ ]$$ + ˇ + "} + .replace("$", " ") + .as_str(), + ); + + // Case 5: Adding newline with content adds marker preserving indentation + cx.set_state(indoc! {" + - [ ] task + - [ ] indentedˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ ] task + - [ ] indented + - [ ] ˇ + "}); + + // Case 6: Adding newline with cursor right after prefix, unindents + cx.set_state(indoc! {" + - [ ] task + - [ ] sub task + - [ ] ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ ] task + - [ ] sub task + - [ ] ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + + // Case 7: Adding newline with cursor right after prefix, removes marker + cx.assert_editor_state(indoc! {" + - [ ] task + - [ ] sub task + - [ ] ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ ] task + - [ ] sub task + ˇ + "}); + + // Case 8: Cursor before or inside prefix does not add marker + cx.set_state(indoc! {" + ˇ- [ ] task + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + + ˇ- [ ] task + "}); + + cx.set_state(indoc! {" + - [ˇ ] task + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ + ˇ + ] task + "}); +} + +#[gpui::test] +async fn test_newline_unordered_list_continuation(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = Some(2.try_into().unwrap()); + }); + + let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Case 1: Adding newline after (whitespace + marker + any non-whitespace) adds marker + cx.set_state(indoc! {" + - itemˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - item + - ˇ + "}); + + // Case 2: Works with different markers + cx.set_state(indoc! {" + * starred itemˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + * starred item + * ˇ + "}); + + cx.set_state(indoc! {" + + plus itemˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + + plus item + + ˇ + "}); + + // Case 3: Cursor position doesn't matter - content after marker is what counts + cx.set_state(indoc! {" + - itˇem + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - it + - ˇem + "}); + + // Case 4: Adding newline after (whitespace + marker + some whitespace) does NOT add marker + cx.set_state(indoc! {" + - ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state( + indoc! {" + - $ + ˇ + "} + .replace("$", " ") + .as_str(), + ); + + // Case 5: Adding newline with content adds marker preserving indentation + cx.set_state(indoc! {" + - item + - indentedˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - item + - indented + - ˇ + "}); + + // Case 6: Adding newline with cursor right after marker, unindents + cx.set_state(indoc! {" + - item + - sub item + - ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - item + - sub item + - ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + + // Case 7: Adding newline with cursor right after marker, removes marker + cx.assert_editor_state(indoc! {" + - item + - sub item + - ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - item + - sub item + ˇ + "}); + + // Case 8: Cursor before or inside prefix does not add marker + cx.set_state(indoc! {" + ˇ- item + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + + ˇ- item + "}); + + cx.set_state(indoc! {" + -ˇ item + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - + ˇitem + "}); +} + +#[gpui::test] +async fn test_newline_ordered_list_continuation(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = Some(2.try_into().unwrap()); + }); + + let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Case 1: Adding newline after (whitespace + marker + any non-whitespace) increments number + cx.set_state(indoc! {" + 1. first itemˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. first item + 2. ˇ + "}); + + // Case 2: Works with larger numbers + cx.set_state(indoc! {" + 10. tenth itemˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 10. tenth item + 11. ˇ + "}); + + // Case 3: Cursor position doesn't matter - content after marker is what counts + cx.set_state(indoc! {" + 1. itˇem + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. it + 2. ˇem + "}); + + // Case 4: Adding newline after (whitespace + marker + some whitespace) does NOT add marker + cx.set_state(indoc! {" + 1. ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state( + indoc! {" + 1. $ + ˇ + "} + .replace("$", " ") + .as_str(), + ); + + // Case 5: Adding newline with content adds marker preserving indentation + cx.set_state(indoc! {" + 1. item + 2. indentedˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. item + 2. indented + 3. ˇ + "}); + + // Case 6: Adding newline with cursor right after marker, unindents + cx.set_state(indoc! {" + 1. item + 2. sub item + 3. ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. item + 2. sub item + 1. ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + + // Case 7: Adding newline with cursor right after marker, removes marker + cx.assert_editor_state(indoc! {" + 1. item + 2. sub item + 1. ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. item + 2. sub item + ˇ + "}); + + // Case 8: Cursor before or inside prefix does not add marker + cx.set_state(indoc! {" + ˇ1. item + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + + ˇ1. item + "}); + + cx.set_state(indoc! {" + 1ˇ. item + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1 + ˇ. item + "}); +} + +#[gpui::test] +async fn test_newline_should_not_autoindent_ordered_list(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = Some(2.try_into().unwrap()); + }); + + let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Case 1: Adding newline after (whitespace + marker + any non-whitespace) increments number + cx.set_state(indoc! {" + 1. first item + 1. sub first item + 2. sub second item + 3. ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. first item + 1. sub first item + 2. sub second item + 1. ˇ + "}); +} + +#[gpui::test] +async fn test_tab_list_indent(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = Some(2.try_into().unwrap()); + }); + + let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Case 1: Unordered list - cursor after prefix, adds indent before prefix + cx.set_state(indoc! {" + - ˇitem + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$- ˇitem + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 2: Task list - cursor after prefix + cx.set_state(indoc! {" + - [ ] ˇtask + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$- [ ] ˇtask + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 3: Ordered list - cursor after prefix + cx.set_state(indoc! {" + 1. ˇfirst + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$1. ˇfirst + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 4: With existing indentation - adds more indent + let initial = indoc! {" + $$- ˇitem + "}; + cx.set_state(initial.replace("$", " ").as_str()); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$$$- ˇitem + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 5: Empty list item + cx.set_state(indoc! {" + - ˇ + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$- ˇ + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 6: Cursor at end of line with content + cx.set_state(indoc! {" + - itemˇ + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$- itemˇ + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 7: Cursor at start of list item, indents it + cx.set_state(indoc! {" + - item + ˇ - sub item + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + - item + ˇ - sub item + "}; + cx.assert_editor_state(expected); + + // Case 8: Cursor at start of list item, moves the cursor when "indent_list_on_tab" is false + cx.update_editor(|_, _, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.indent_list_on_tab = Some(false); + }); + }); + }); + cx.set_state(indoc! {" + - item + ˇ - sub item + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + - item + ˇ- sub item + "}; + cx.assert_editor_state(expected); +} + #[gpui::test] async fn test_local_worktree_trust(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index a573e3d78a4de03c6ccf382c80bc33eaf0b5690d..290cad4e4497015ef63f79e58a0dacf231168c9f 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -827,6 +827,15 @@ pub struct LanguageConfig { /// Delimiters and configuration for recognizing and formatting documentation comments. #[serde(default, alias = "documentation")] pub documentation_comment: Option, + /// List markers that are inserted unchanged on newline (e.g., `- `, `* `, `+ `). + #[serde(default)] + pub unordered_list: Vec>, + /// Configuration for ordered lists with auto-incrementing numbers on newline (e.g., `1. ` becomes `2. `). + #[serde(default)] + pub ordered_list: Vec, + /// Configuration for task lists where multiple markers map to a single continuation prefix (e.g., `- [x] ` continues as `- [ ] `). + #[serde(default)] + pub task_list: Option, /// A list of additional regex patterns that should be treated as prefixes /// for creating boundaries during rewrapping, ensuring content from one /// prefixed section doesn't merge with another (e.g., markdown list items). @@ -898,6 +907,24 @@ pub struct DecreaseIndentConfig { pub valid_after: Vec, } +/// Configuration for continuing ordered lists with auto-incrementing numbers. +#[derive(Clone, Debug, Deserialize, JsonSchema)] +pub struct OrderedListConfig { + /// A regex pattern with a capture group for the number portion (e.g., `(\\d+)\\. `). + pub pattern: String, + /// A format string where `{1}` is replaced with the incremented number (e.g., `{1}. `). + pub format: String, +} + +/// Configuration for continuing task lists on newline. +#[derive(Clone, Debug, Deserialize, JsonSchema)] +pub struct TaskListConfig { + /// The list markers to match (e.g., `- [ ] `, `- [x] `). + pub prefixes: Vec>, + /// The marker to insert when continuing the list on a new line (e.g., `- [ ] `). + pub continuation: Arc, +} + #[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)] pub struct LanguageMatcher { /// Given a list of `LanguageConfig`'s, the language of a file can be determined based on the path extension matching any of the `path_suffixes`. @@ -1068,6 +1095,9 @@ impl Default for LanguageConfig { line_comments: Default::default(), block_comment: Default::default(), documentation_comment: Default::default(), + unordered_list: Default::default(), + ordered_list: Default::default(), + task_list: Default::default(), rewrap_prefixes: Default::default(), scope_opt_in_language_servers: Default::default(), overrides: Default::default(), @@ -2153,6 +2183,21 @@ impl LanguageScope { self.language.config.documentation_comment.as_ref() } + /// Returns list markers that are inserted unchanged on newline (e.g., `- `, `* `, `+ `). + pub fn unordered_list(&self) -> &[Arc] { + &self.language.config.unordered_list + } + + /// Returns configuration for ordered lists with auto-incrementing numbers (e.g., `1. ` becomes `2. `). + pub fn ordered_list(&self) -> &[OrderedListConfig] { + &self.language.config.ordered_list + } + + /// Returns configuration for task list continuation, if any (e.g., `- [x] ` continues as `- [ ] `). + pub fn task_list(&self) -> Option<&TaskListConfig> { + self.language.config.task_list.as_ref() + } + /// Returns additional regex patterns that act as prefix markers for creating /// boundaries during rewrapping. /// diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index fccaa545b79c1f24589889df8fcd163fbc5b6c7d..205f2431c6d9deeaa7661b583caa516bdc77ae79 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -122,6 +122,10 @@ pub struct LanguageSettings { pub whitespace_map: WhitespaceMap, /// Whether to start a new line with a comment when a previous line is a comment as well. pub extend_comment_on_newline: bool, + /// Whether to continue markdown lists when pressing enter. + pub extend_list_on_newline: bool, + /// Whether to indent list items when pressing tab after a list marker. + pub indent_list_on_tab: bool, /// Inlay hint related settings. pub inlay_hints: InlayHintSettings, /// Whether to automatically close brackets. @@ -567,6 +571,8 @@ impl settings::Settings for AllLanguageSettings { tab: SharedString::new(whitespace_map.tab.unwrap().to_string()), }, extend_comment_on_newline: settings.extend_comment_on_newline.unwrap(), + extend_list_on_newline: settings.extend_list_on_newline.unwrap(), + indent_list_on_tab: settings.indent_list_on_tab.unwrap(), inlay_hints: InlayHintSettings { enabled: inlay_hints.enabled.unwrap(), show_value_hints: inlay_hints.show_value_hints.unwrap(), diff --git a/crates/languages/src/markdown/config.toml b/crates/languages/src/markdown/config.toml index 84c79d2538a0af470ec16d55fe9cf2d1ae05805b..423a4c008f6e8a64f3c4e883b0d6e2bde65c88ae 100644 --- a/crates/languages/src/markdown/config.toml +++ b/crates/languages/src/markdown/config.toml @@ -20,6 +20,9 @@ rewrap_prefixes = [ ">\\s*", "[-*+]\\s+\\[[\\sx]\\]\\s+" ] +unordered_list = ["- ", "* ", "+ "] +ordered_list = [{ pattern = "(\\d+)\\. ", format = "{1}. " }] +task_list = { prefixes = ["- [ ] ", "- [x] "], continuation = "- [ ] " } auto_indent_on_paste = false auto_indent_using_last_non_empty_line = false diff --git a/crates/settings/src/settings_content/language.rs b/crates/settings/src/settings_content/language.rs index f9c85f18f380a7ad82b0d8bc202fe3763ba3a832..cf8cf7b63589e84a96e6b9d92f23a4488479d1f3 100644 --- a/crates/settings/src/settings_content/language.rs +++ b/crates/settings/src/settings_content/language.rs @@ -363,6 +363,14 @@ pub struct LanguageSettingsContent { /// /// Default: true pub extend_comment_on_newline: Option, + /// Whether to continue markdown lists when pressing enter. + /// + /// Default: true + pub extend_list_on_newline: Option, + /// Whether to indent list items when pressing tab after a list marker. + /// + /// Default: true + pub indent_list_on_tab: Option, /// Inlay hint related settings. pub inlay_hints: Option, /// Whether to automatically type closing characters for you. For example, diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index d77754f611e8eb1746ee9061ce5b5e1dfdbdafdb..64343b05fd57c33eb9cfb0d8cb8674971266b464 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -430,6 +430,8 @@ impl VsCodeSettings { enable_language_server: None, ensure_final_newline_on_save: self.read_bool("files.insertFinalNewline"), 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 diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index 8a638d9f7857e1a55aaa5589a77110a7b803bbfe..81318aa8885fe883acc394e7fe983d7721dd33a5 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -1585,6 +1585,26 @@ Positive `integer` value between 1 and 32. Values outside of this range will be `boolean` values +## Extend List On Newline + +- Description: Whether to continue lists when pressing Enter at the end of a list item. Supports unordered, ordered, and task lists. Pressing Enter on an empty list item removes the marker and exits the list. +- Setting: `extend_list_on_newline` +- Default: `true` + +**Options** + +`boolean` values + +## Indent List On Tab + +- Description: Whether to indent list items when pressing Tab on a line containing only a list marker. This enables quick creation of nested lists. +- Setting: `indent_list_on_tab` +- Default: `true` + +**Options** + +`boolean` values + ## Status Bar - Description: Control various elements in the status bar. Note that some items in the status bar have their own settings set elsewhere. diff --git a/docs/src/languages/markdown.md b/docs/src/languages/markdown.md index 36ce734f7cfbcc066bb8026568209738655a6be9..64c9e7070569a23daa5bcb8aa4dace12e0021b03 100644 --- a/docs/src/languages/markdown.md +++ b/docs/src/languages/markdown.md @@ -33,6 +33,40 @@ Zed supports using Prettier to automatically re-format Markdown documents. You c }, ``` +### List Continuation + +Zed automatically continues lists when you press Enter at the end of a list item. Supported list types: + +- Unordered lists (`-`, `*`, or `+` markers) +- Ordered lists (numbers are auto-incremented) +- Task lists (`- [ ]` and `- [x]`) + +Pressing Enter on an empty list item removes the marker and exits the list. + +To disable this behavior: + +```json [settings] + "languages": { + "Markdown": { + "extend_list_on_newline": false + } + }, +``` + +### List Indentation + +Zed indents list items when you press Tab while the cursor is on a line containing only a list marker. This allows you to quickly create nested lists. + +To disable this behavior: + +```json [settings] + "languages": { + "Markdown": { + "indent_list_on_tab": false + } + }, +``` + ### Trailing Whitespace By default Zed will remove trailing whitespace on save. If you rely on invisible trailing whitespace being converted to `
` in Markdown files you can disable this behavior with: From 32600f255aea17ffc4557b39f2fc8c275db3b59a Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:14:31 -0300 Subject: [PATCH 14/19] gpui: Fix truncation flickering (#45373) It's been a little that we've noticed some flickering and other weird resizing behavior with text truncation in Zed: https://github.com/user-attachments/assets/4d5691a3-cd3d-45e0-8b96-74a4e0e273d2 https://github.com/user-attachments/assets/d1d0e587-7676-4da0-8818-f4e50f0e294e Initially, we suspected this could be due to how we calculate the length of a line to insert truncation, which is based first on the length of each individual character, and then second goes through a pass calculating the line length as a whole. This could cause mismatch and culminate in our bug. However, even though that felt like a reasonable suspicion, I realized something rather simple at some point: the `truncate` and `truncate_start` methods in the `Label` didn't use `whitespace_nowrap`. If you take Tailwind as an example, their `truncate` utility class takes `overflow: hidden; text-overflow: ellipsis; white-space: nowrap;`. This pointed out to a potential bug with `whitespace_nowrap` where that was blocking truncation entirely, even though that's technically part of what's necessary to truncate as you don't want text that will be truncated to wrap. Ultimately, what was happening was that the text element was caching its layout based on its `wrap_width` but not considering its `truncate_width`. The truncate width is essentially the new definitive width of the text based on the available space, which was never being computed. So the fix here was to add `truncate_width.is_none()` to the cache validation check, so that it only uses the cached text element size _if the truncation width is untouched_. But if that changes, we need to account for the new width. Then, in the Label component, we added `min_w_0` to allow the label div to shrink below its original size, and finally, we added `whitespace_nowrap()` as the cache check fundamentally fixed that method's problem. In a future PR, we can basically remove the `single_line()` label method because: 1) whenever you want a single label, you most likely want it to truncate, and 2) most instances of `truncate` are already followed by `single_line` in Zed today, so we can cut that part. Result is no flickering with truncated labels! https://github.com/user-attachments/assets/ae17cbde-0de7-42ca-98a4-22fcb452016b Release Notes: - Fixed a bug in GPUI where truncated text would flicker as you resized the container in which the text was in. Co-authored-by: Lukas Wirth --- crates/gpui/src/elements/text.rs | 10 ++++++++-- crates/ui/src/components/label/label_like.rs | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 942a0a326526431dc65f389e9cff67bac252d571..770c1f871432afbecc9ffd4e903dfeddcfcba6ee 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -372,11 +372,17 @@ impl TextLayout { (None, "".into(), TruncateFrom::End) }; + // Only use cached layout if: + // 1. We have a cached size + // 2. wrap_width matches (or both are None) + // 3. truncate_width is None (if truncate_width is Some, we need to re-layout + // because the previous layout may have been computed without truncation) if let Some(text_layout) = element_state.0.borrow().as_ref() - && text_layout.size.is_some() + && let Some(size) = text_layout.size && (wrap_width.is_none() || wrap_width == text_layout.wrap_width) + && truncate_width.is_none() { - return text_layout.size.unwrap(); + return size; } let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size); diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index f6e7a1b893d54fff425618d5c604f591144a7385..03fde4083d5e9a8e07f38c830edd5116f14e6d70 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -241,10 +241,16 @@ impl RenderOnce for LabelLike { .when(self.strikethrough, |this| this.line_through()) .when(self.single_line, |this| this.whitespace_nowrap()) .when(self.truncate, |this| { - this.overflow_x_hidden().text_ellipsis() + this.min_w_0() + .overflow_x_hidden() + .whitespace_nowrap() + .text_ellipsis() }) .when(self.truncate_start, |this| { - this.overflow_x_hidden().text_ellipsis_start() + this.min_w_0() + .overflow_x_hidden() + .whitespace_nowrap() + .text_ellipsis_start() }) .text_color(color) .font_weight( From e05dcecac40566c0a49b9754934d9030e4e7ad76 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Fri, 19 Dec 2025 10:21:56 -0600 Subject: [PATCH 15/19] Make `pane::CloseAllItems` best effort (#45368) Closes #ISSUE Release Notes: - Fixed an issue where the `pane: close all items` action would give up if you hit "Cancel" on the prompt for what to do with a dirty buffer --- crates/workspace/src/pane.rs | 81 ++++++++++++++++++++++++++----- crates/workspace/src/workspace.rs | 26 ++++++---- 2 files changed, 85 insertions(+), 22 deletions(-) diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index f6256aee46b9e2b5c29c020e9ee12f6ff510210f..dd17c338a935571f4d0fe9d46b3b10fac9ffe218 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -1846,6 +1846,7 @@ impl Pane { } for item_to_close in items_to_close { + let mut should_close = true; let mut should_save = true; if save_intent == SaveIntent::Close { workspace.update(cx, |workspace, cx| { @@ -1861,7 +1862,7 @@ impl Pane { { Ok(success) => { if !success { - break; + should_close = false; } } Err(err) => { @@ -1880,23 +1881,25 @@ impl Pane { })?; match answer.await { Ok(0) => {} - Ok(1..) | Err(_) => break, + Ok(1..) | Err(_) => should_close = false, } } } } // Remove the item from the pane. - pane.update_in(cx, |pane, window, cx| { - pane.remove_item( - item_to_close.item_id(), - false, - pane.close_pane_if_empty, - window, - cx, - ); - }) - .ok(); + if should_close { + pane.update_in(cx, |pane, window, cx| { + pane.remove_item( + item_to_close.item_id(), + false, + pane.close_pane_if_empty, + window, + cx, + ); + }) + .ok(); + } } pane.update(cx, |_, cx| cx.notify()).ok(); @@ -6614,6 +6617,60 @@ mod tests { cx.simulate_prompt_answer("Discard all"); save.await.unwrap(); assert_item_labels(&pane, [], cx); + + add_labeled_item(&pane, "A", true, cx).update(cx, |item, cx| { + item.project_items + .push(TestProjectItem::new_dirty(1, "A.txt", cx)) + }); + add_labeled_item(&pane, "B", true, cx).update(cx, |item, cx| { + item.project_items + .push(TestProjectItem::new_dirty(2, "B.txt", cx)) + }); + add_labeled_item(&pane, "C", true, cx).update(cx, |item, cx| { + item.project_items + .push(TestProjectItem::new_dirty(3, "C.txt", cx)) + }); + assert_item_labels(&pane, ["A^", "B^", "C*^"], cx); + + let close_task = pane.update_in(cx, |pane, window, cx| { + pane.close_all_items( + &CloseAllItems { + save_intent: None, + close_pinned: false, + }, + window, + cx, + ) + }); + + cx.executor().run_until_parked(); + cx.simulate_prompt_answer("Discard all"); + close_task.await.unwrap(); + assert_item_labels(&pane, [], cx); + + add_labeled_item(&pane, "Clean1", false, cx); + add_labeled_item(&pane, "Dirty", true, cx).update(cx, |item, cx| { + item.project_items + .push(TestProjectItem::new_dirty(1, "Dirty.txt", cx)) + }); + add_labeled_item(&pane, "Clean2", false, cx); + assert_item_labels(&pane, ["Clean1", "Dirty^", "Clean2*"], cx); + + let close_task = pane.update_in(cx, |pane, window, cx| { + pane.close_all_items( + &CloseAllItems { + save_intent: None, + close_pinned: false, + }, + window, + cx, + ) + }); + + cx.executor().run_until_parked(); + cx.simulate_prompt_answer("Cancel"); + close_task.await.unwrap(); + assert_item_labels(&pane, ["Dirty*^"], cx); } #[gpui::test] diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index c88386281e73b243dbddd6cb00c80fb26595409e..fa8e3a3dc2af33054907ea8a8c1ba095a3259207 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -9424,7 +9424,7 @@ mod tests { let right_pane = right_pane.await.unwrap(); cx.focus(&right_pane); - let mut close = right_pane.update_in(cx, |pane, window, cx| { + let close = right_pane.update_in(cx, |pane, window, cx| { pane.close_all_items(&CloseAllItems::default(), window, cx) .unwrap() }); @@ -9436,9 +9436,16 @@ mod tests { assert!(!msg.contains("3.txt")); assert!(!msg.contains("4.txt")); + // With best-effort close, cancelling item 1 keeps it open but items 4 + // and (3,4) still close since their entries exist in left pane. cx.simulate_prompt_answer("Cancel"); close.await; + right_pane.read_with(cx, |pane, _| { + assert_eq!(pane.items_len(), 1); + }); + + // Remove item 3 from left pane, making (2,3) the only item with entry 3. left_pane .update_in(cx, |left_pane, window, cx| { left_pane.close_item_by_id( @@ -9451,26 +9458,25 @@ mod tests { .await .unwrap(); - close = right_pane.update_in(cx, |pane, window, cx| { + let close = left_pane.update_in(cx, |pane, window, cx| { pane.close_all_items(&CloseAllItems::default(), window, cx) .unwrap() }); cx.executor().run_until_parked(); let details = cx.pending_prompt().unwrap().1; - assert!(details.contains("1.txt")); - assert!(!details.contains("2.txt")); + assert!(details.contains("0.txt")); assert!(details.contains("3.txt")); - // ideally this assertion could be made, but today we can only - // save whole items not project items, so the orphaned item 3 causes - // 4 to be saved too. - // assert!(!details.contains("4.txt")); + assert!(details.contains("4.txt")); + // Ideally 2.txt wouldn't appear since entry 2 still exists in item 2. + // But we can only save whole items, so saving (2,3) for entry 3 includes 2. + // assert!(!details.contains("2.txt")); cx.simulate_prompt_answer("Save all"); - cx.executor().run_until_parked(); close.await; - right_pane.read_with(cx, |pane, _| { + + left_pane.read_with(cx, |pane, _| { assert_eq!(pane.items_len(), 0); }); } From d7e41f74fb3e0279b24f190c19b4ccd3680d01b5 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Fri, 19 Dec 2025 13:31:27 -0300 Subject: [PATCH 16/19] search: Respect macOS' find pasteboard (#45311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #17467 Release Notes: - On macOS, buffer search now syncs with the system find pasteboard, allowing ⌘E and ⌘G to work seamlessly across Zed and other apps. --- crates/gpui/src/app.rs | 36 +- crates/gpui/src/platform.rs | 12 +- crates/gpui/src/platform/mac.rs | 3 +- .../src/platform/mac/attributed_string.rs | 129 ------ crates/gpui/src/platform/mac/pasteboard.rs | 344 +++++++++++++++ crates/gpui/src/platform/mac/platform.rs | 397 ++---------------- crates/gpui/src/platform/test/platform.rs | 24 +- crates/search/src/buffer_search.rs | 89 +++- 8 files changed, 499 insertions(+), 535 deletions(-) delete mode 100644 crates/gpui/src/platform/mac/attributed_string.rs create mode 100644 crates/gpui/src/platform/mac/pasteboard.rs diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 75600a9ee1b440a092a89456cbe8fbabe6fdccfa..96f815ac0b592600f22b3c9b9686571487ff77a2 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -1077,11 +1077,9 @@ impl App { self.platform.window_appearance() } - /// Writes data to the primary selection buffer. - /// Only available on Linux. - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - pub fn write_to_primary(&self, item: ClipboardItem) { - self.platform.write_to_primary(item) + /// Reads data from the platform clipboard. + pub fn read_from_clipboard(&self) -> Option { + self.platform.read_from_clipboard() } /// Writes data to the platform clipboard. @@ -1096,9 +1094,31 @@ impl App { self.platform.read_from_primary() } - /// Reads data from the platform clipboard. - pub fn read_from_clipboard(&self) -> Option { - self.platform.read_from_clipboard() + /// Writes data to the primary selection buffer. + /// Only available on Linux. + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + pub fn write_to_primary(&self, item: ClipboardItem) { + self.platform.write_to_primary(item) + } + + /// Reads data from macOS's "Find" pasteboard. + /// + /// Used to share the current search string between apps. + /// + /// https://developer.apple.com/documentation/appkit/nspasteboard/name-swift.struct/find + #[cfg(target_os = "macos")] + pub fn read_from_find_pasteboard(&self) -> Option { + self.platform.read_from_find_pasteboard() + } + + /// Writes data to macOS's "Find" pasteboard. + /// + /// Used to share the current search string between apps. + /// + /// https://developer.apple.com/documentation/appkit/nspasteboard/name-swift.struct/find + #[cfg(target_os = "macos")] + pub fn write_to_find_pasteboard(&self, item: ClipboardItem) { + self.platform.write_to_find_pasteboard(item) } /// Writes credentials to the platform keychain. diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 22f4c46921132a7b8badfb7afd4fd38058c638b4..112775890ef6e478f0b2d347bc9c9ae56dac3c73 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -262,12 +262,18 @@ pub(crate) trait Platform: 'static { fn set_cursor_style(&self, style: CursorStyle); fn should_auto_hide_scrollbars(&self) -> bool; - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - fn write_to_primary(&self, item: ClipboardItem); + fn read_from_clipboard(&self) -> Option; fn write_to_clipboard(&self, item: ClipboardItem); + #[cfg(any(target_os = "linux", target_os = "freebsd"))] fn read_from_primary(&self) -> Option; - fn read_from_clipboard(&self) -> Option; + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + fn write_to_primary(&self, item: ClipboardItem); + + #[cfg(target_os = "macos")] + fn read_from_find_pasteboard(&self) -> Option; + #[cfg(target_os = "macos")] + fn write_to_find_pasteboard(&self, item: ClipboardItem); fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task>; fn read_credentials(&self, url: &str) -> Task)>>>; diff --git a/crates/gpui/src/platform/mac.rs b/crates/gpui/src/platform/mac.rs index aa056846e6bc56e53d95c41a44444dbb89a16237..a229ec7dce928597ec73b1f4be50edd1ea3e5114 100644 --- a/crates/gpui/src/platform/mac.rs +++ b/crates/gpui/src/platform/mac.rs @@ -5,6 +5,7 @@ mod display; mod display_link; mod events; mod keyboard; +mod pasteboard; #[cfg(feature = "screen-capture")] mod screen_capture; @@ -21,8 +22,6 @@ use metal_renderer as renderer; #[cfg(feature = "macos-blade")] use crate::platform::blade as renderer; -mod attributed_string; - #[cfg(feature = "font-kit")] mod open_type; diff --git a/crates/gpui/src/platform/mac/attributed_string.rs b/crates/gpui/src/platform/mac/attributed_string.rs deleted file mode 100644 index 42fe1e5bf7a396a4eaa8ade26977a207d43b49b5..0000000000000000000000000000000000000000 --- a/crates/gpui/src/platform/mac/attributed_string.rs +++ /dev/null @@ -1,129 +0,0 @@ -use cocoa::base::id; -use cocoa::foundation::NSRange; -use objc::{class, msg_send, sel, sel_impl}; - -/// The `cocoa` crate does not define NSAttributedString (and related Cocoa classes), -/// which are needed for copying rich text (that is, text intermingled with images) -/// to the clipboard. This adds access to those APIs. -#[allow(non_snake_case)] -pub trait NSAttributedString: Sized { - unsafe fn alloc(_: Self) -> id { - msg_send![class!(NSAttributedString), alloc] - } - - unsafe fn init_attributed_string(self, string: id) -> id; - unsafe fn appendAttributedString_(self, attr_string: id); - unsafe fn RTFDFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id; - unsafe fn RTFFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id; - unsafe fn string(self) -> id; -} - -impl NSAttributedString for id { - unsafe fn init_attributed_string(self, string: id) -> id { - msg_send![self, initWithString: string] - } - - unsafe fn appendAttributedString_(self, attr_string: id) { - let _: () = msg_send![self, appendAttributedString: attr_string]; - } - - unsafe fn RTFDFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id { - msg_send![self, RTFDFromRange: range documentAttributes: attrs] - } - - unsafe fn RTFFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id { - msg_send![self, RTFFromRange: range documentAttributes: attrs] - } - - unsafe fn string(self) -> id { - msg_send![self, string] - } -} - -pub trait NSMutableAttributedString: NSAttributedString { - unsafe fn alloc(_: Self) -> id { - msg_send![class!(NSMutableAttributedString), alloc] - } -} - -impl NSMutableAttributedString for id {} - -#[cfg(test)] -mod tests { - use crate::platform::mac::ns_string; - - use super::*; - use cocoa::appkit::NSImage; - use cocoa::base::nil; - use cocoa::foundation::NSAutoreleasePool; - #[test] - #[ignore] // This was SIGSEGV-ing on CI but not locally; need to investigate https://github.com/zed-industries/zed/actions/runs/10362363230/job/28684225486?pr=15782#step:4:1348 - fn test_nsattributed_string() { - // TODO move these to parent module once it's actually ready to be used - #[allow(non_snake_case)] - pub trait NSTextAttachment: Sized { - unsafe fn alloc(_: Self) -> id { - msg_send![class!(NSTextAttachment), alloc] - } - } - - impl NSTextAttachment for id {} - - unsafe { - let image: id = { - let img: id = msg_send![class!(NSImage), alloc]; - let img: id = msg_send![img, initWithContentsOfFile: ns_string("test.jpeg")]; - let img: id = msg_send![img, autorelease]; - img - }; - let _size = image.size(); - - let string = ns_string("Test String"); - let attr_string = NSMutableAttributedString::alloc(nil) - .init_attributed_string(string) - .autorelease(); - let hello_string = ns_string("Hello World"); - let hello_attr_string = NSAttributedString::alloc(nil) - .init_attributed_string(hello_string) - .autorelease(); - attr_string.appendAttributedString_(hello_attr_string); - - let attachment: id = msg_send![NSTextAttachment::alloc(nil), autorelease]; - let _: () = msg_send![attachment, setImage: image]; - let image_attr_string = - msg_send![class!(NSAttributedString), attributedStringWithAttachment: attachment]; - attr_string.appendAttributedString_(image_attr_string); - - let another_string = ns_string("Another String"); - let another_attr_string = NSAttributedString::alloc(nil) - .init_attributed_string(another_string) - .autorelease(); - attr_string.appendAttributedString_(another_attr_string); - - let _len: cocoa::foundation::NSUInteger = msg_send![attr_string, length]; - - /////////////////////////////////////////////////// - // pasteboard.clearContents(); - - let rtfd_data = attr_string.RTFDFromRange_documentAttributes_( - NSRange::new(0, msg_send![attr_string, length]), - nil, - ); - assert_ne!(rtfd_data, nil); - // if rtfd_data != nil { - // pasteboard.setData_forType(rtfd_data, NSPasteboardTypeRTFD); - // } - - // let rtf_data = attributed_string.RTFFromRange_documentAttributes_( - // NSRange::new(0, attributed_string.length()), - // nil, - // ); - // if rtf_data != nil { - // pasteboard.setData_forType(rtf_data, NSPasteboardTypeRTF); - // } - - // let plain_text = attributed_string.string(); - // pasteboard.setString_forType(plain_text, NSPasteboardTypeString); - } - } -} diff --git a/crates/gpui/src/platform/mac/pasteboard.rs b/crates/gpui/src/platform/mac/pasteboard.rs new file mode 100644 index 0000000000000000000000000000000000000000..38710951f15b25515d906afc738c5b971b1bb135 --- /dev/null +++ b/crates/gpui/src/platform/mac/pasteboard.rs @@ -0,0 +1,344 @@ +use core::slice; +use std::ffi::c_void; + +use cocoa::{ + appkit::{NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeString, NSPasteboardTypeTIFF}, + base::{id, nil}, + foundation::NSData, +}; +use objc::{msg_send, runtime::Object, sel, sel_impl}; +use strum::IntoEnumIterator as _; + +use crate::{ + ClipboardEntry, ClipboardItem, ClipboardString, Image, ImageFormat, asset_cache::hash, + platform::mac::ns_string, +}; + +pub struct Pasteboard { + inner: id, + text_hash_type: id, + metadata_type: id, +} + +impl Pasteboard { + pub fn general() -> Self { + unsafe { Self::new(NSPasteboard::generalPasteboard(nil)) } + } + + pub fn find() -> Self { + unsafe { Self::new(NSPasteboard::pasteboardWithName(nil, NSPasteboardNameFind)) } + } + + #[cfg(test)] + pub fn unique() -> Self { + unsafe { Self::new(NSPasteboard::pasteboardWithUniqueName(nil)) } + } + + unsafe fn new(inner: id) -> Self { + Self { + inner, + text_hash_type: unsafe { ns_string("zed-text-hash") }, + metadata_type: unsafe { ns_string("zed-metadata") }, + } + } + + pub fn read(&self) -> Option { + // First, see if it's a string. + unsafe { + let pasteboard_types: id = self.inner.types(); + let string_type: id = ns_string("public.utf8-plain-text"); + + if msg_send![pasteboard_types, containsObject: string_type] { + let data = self.inner.dataForType(string_type); + if data == nil { + return None; + } else if data.bytes().is_null() { + // https://developer.apple.com/documentation/foundation/nsdata/1410616-bytes?language=objc + // "If the length of the NSData object is 0, this property returns nil." + return Some(self.read_string(&[])); + } else { + let bytes = + slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize); + + return Some(self.read_string(bytes)); + } + } + + // If it wasn't a string, try the various supported image types. + for format in ImageFormat::iter() { + if let Some(item) = self.read_image(format) { + return Some(item); + } + } + } + + // If it wasn't a string or a supported image type, give up. + None + } + + fn read_image(&self, format: ImageFormat) -> Option { + let mut ut_type: UTType = format.into(); + + unsafe { + let types: id = self.inner.types(); + if msg_send![types, containsObject: ut_type.inner()] { + self.data_for_type(ut_type.inner_mut()).map(|bytes| { + let bytes = bytes.to_vec(); + let id = hash(&bytes); + + ClipboardItem { + entries: vec![ClipboardEntry::Image(Image { format, bytes, id })], + } + }) + } else { + None + } + } + } + + fn read_string(&self, text_bytes: &[u8]) -> ClipboardItem { + unsafe { + let text = String::from_utf8_lossy(text_bytes).to_string(); + let metadata = self + .data_for_type(self.text_hash_type) + .and_then(|hash_bytes| { + let hash_bytes = hash_bytes.try_into().ok()?; + let hash = u64::from_be_bytes(hash_bytes); + let metadata = self.data_for_type(self.metadata_type)?; + + if hash == ClipboardString::text_hash(&text) { + String::from_utf8(metadata.to_vec()).ok() + } else { + None + } + }); + + ClipboardItem { + entries: vec![ClipboardEntry::String(ClipboardString { text, metadata })], + } + } + } + + unsafe fn data_for_type(&self, kind: id) -> Option<&[u8]> { + unsafe { + let data = self.inner.dataForType(kind); + if data == nil { + None + } else { + Some(slice::from_raw_parts( + data.bytes() as *mut u8, + data.length() as usize, + )) + } + } + } + + pub fn write(&self, item: ClipboardItem) { + unsafe { + match item.entries.as_slice() { + [] => { + // Writing an empty list of entries just clears the clipboard. + self.inner.clearContents(); + } + [ClipboardEntry::String(string)] => { + self.write_plaintext(string); + } + [ClipboardEntry::Image(image)] => { + self.write_image(image); + } + [ClipboardEntry::ExternalPaths(_)] => {} + _ => { + // Agus NB: We're currently only writing string entries to the clipboard when we have more than one. + // + // This was the existing behavior before I refactored the outer clipboard code: + // https://github.com/zed-industries/zed/blob/65f7412a0265552b06ce122655369d6cc7381dd6/crates/gpui/src/platform/mac/platform.rs#L1060-L1110 + // + // Note how `any_images` is always `false`. We should fix that, but that's orthogonal to the refactor. + + let mut combined = ClipboardString { + text: String::new(), + metadata: None, + }; + + for entry in item.entries { + match entry { + ClipboardEntry::String(text) => { + combined.text.push_str(&text.text()); + if combined.metadata.is_none() { + combined.metadata = text.metadata; + } + } + _ => {} + } + } + + self.write_plaintext(&combined); + } + } + } + } + + fn write_plaintext(&self, string: &ClipboardString) { + unsafe { + self.inner.clearContents(); + + let text_bytes = NSData::dataWithBytes_length_( + nil, + string.text.as_ptr() as *const c_void, + string.text.len() as u64, + ); + self.inner + .setData_forType(text_bytes, NSPasteboardTypeString); + + if let Some(metadata) = string.metadata.as_ref() { + let hash_bytes = ClipboardString::text_hash(&string.text).to_be_bytes(); + let hash_bytes = NSData::dataWithBytes_length_( + nil, + hash_bytes.as_ptr() as *const c_void, + hash_bytes.len() as u64, + ); + self.inner.setData_forType(hash_bytes, self.text_hash_type); + + let metadata_bytes = NSData::dataWithBytes_length_( + nil, + metadata.as_ptr() as *const c_void, + metadata.len() as u64, + ); + self.inner + .setData_forType(metadata_bytes, self.metadata_type); + } + } + } + + unsafe fn write_image(&self, image: &Image) { + unsafe { + self.inner.clearContents(); + + let bytes = NSData::dataWithBytes_length_( + nil, + image.bytes.as_ptr() as *const c_void, + image.bytes.len() as u64, + ); + + self.inner + .setData_forType(bytes, Into::::into(image.format).inner_mut()); + } + } +} + +#[link(name = "AppKit", kind = "framework")] +unsafe extern "C" { + /// [Apple's documentation](https://developer.apple.com/documentation/appkit/nspasteboardnamefind?language=objc) + pub static NSPasteboardNameFind: id; +} + +impl From for UTType { + fn from(value: ImageFormat) -> Self { + match value { + ImageFormat::Png => Self::png(), + ImageFormat::Jpeg => Self::jpeg(), + ImageFormat::Tiff => Self::tiff(), + ImageFormat::Webp => Self::webp(), + ImageFormat::Gif => Self::gif(), + ImageFormat::Bmp => Self::bmp(), + ImageFormat::Svg => Self::svg(), + ImageFormat::Ico => Self::ico(), + } + } +} + +// See https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ +pub struct UTType(id); + +impl UTType { + pub fn png() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/png + Self(unsafe { NSPasteboardTypePNG }) // This is a rare case where there's a built-in NSPasteboardType + } + + pub fn jpeg() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/jpeg + Self(unsafe { ns_string("public.jpeg") }) + } + + pub fn gif() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/gif + Self(unsafe { ns_string("com.compuserve.gif") }) + } + + pub fn webp() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/webp + Self(unsafe { ns_string("org.webmproject.webp") }) + } + + pub fn bmp() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/bmp + Self(unsafe { ns_string("com.microsoft.bmp") }) + } + + pub fn svg() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/svg + Self(unsafe { ns_string("public.svg-image") }) + } + + pub fn ico() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico + Self(unsafe { ns_string("com.microsoft.ico") }) + } + + pub fn tiff() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff + Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType + } + + fn inner(&self) -> *const Object { + self.0 + } + + pub fn inner_mut(&self) -> *mut Object { + self.0 as *mut _ + } +} + +#[cfg(test)] +mod tests { + use cocoa::{appkit::NSPasteboardTypeString, foundation::NSData}; + + use crate::{ClipboardEntry, ClipboardItem, ClipboardString}; + + use super::*; + + #[test] + fn test_string() { + let pasteboard = Pasteboard::unique(); + assert_eq!(pasteboard.read(), None); + + let item = ClipboardItem::new_string("1".to_string()); + pasteboard.write(item.clone()); + assert_eq!(pasteboard.read(), Some(item)); + + let item = ClipboardItem { + entries: vec![ClipboardEntry::String( + ClipboardString::new("2".to_string()).with_json_metadata(vec![3, 4]), + )], + }; + pasteboard.write(item.clone()); + assert_eq!(pasteboard.read(), Some(item)); + + let text_from_other_app = "text from other app"; + unsafe { + let bytes = NSData::dataWithBytes_length_( + nil, + text_from_other_app.as_ptr() as *const c_void, + text_from_other_app.len() as u64, + ); + pasteboard + .inner + .setData_forType(bytes, NSPasteboardTypeString); + } + assert_eq!( + pasteboard.read(), + Some(ClipboardItem::new_string(text_from_other_app.to_string())) + ); + } +} diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index ee67f465e34bd8109246f68b311e225aa8f9fd0a..9b32c6735bf6215fecc0455defc4237fd25e8cb0 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -1,29 +1,24 @@ use super::{ - BoolExt, MacKeyboardLayout, MacKeyboardMapper, - attributed_string::{NSAttributedString, NSMutableAttributedString}, - events::key_to_native, - ns_string, renderer, + BoolExt, MacKeyboardLayout, MacKeyboardMapper, events::key_to_native, ns_string, renderer, }; use crate::{ - Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString, - CursorStyle, ForegroundExecutor, Image, ImageFormat, KeyContext, Keymap, MacDispatcher, - MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform, - PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, - PlatformWindow, Result, SystemMenuType, Task, WindowAppearance, WindowParams, hash, + Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, ForegroundExecutor, + KeyContext, Keymap, MacDispatcher, MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu, + PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, + PlatformTextSystem, PlatformWindow, Result, SystemMenuType, Task, WindowAppearance, + WindowParams, platform::mac::pasteboard::Pasteboard, }; use anyhow::{Context as _, anyhow}; use block::ConcreteBlock; use cocoa::{ appkit::{ NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular, - NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSPasteboard, - NSPasteboardTypePNG, NSPasteboardTypeRTF, NSPasteboardTypeRTFD, NSPasteboardTypeString, - NSPasteboardTypeTIFF, NSSavePanel, NSVisualEffectState, NSVisualEffectView, NSWindow, + NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSSavePanel, + NSVisualEffectState, NSVisualEffectView, NSWindow, }, base::{BOOL, NO, YES, id, nil, selector}, foundation::{ - NSArray, NSAutoreleasePool, NSBundle, NSData, NSInteger, NSProcessInfo, NSRange, NSString, - NSUInteger, NSURL, + NSArray, NSAutoreleasePool, NSBundle, NSInteger, NSProcessInfo, NSString, NSUInteger, NSURL, }, }; use core_foundation::{ @@ -49,7 +44,6 @@ use ptr::null_mut; use semver::Version; use std::{ cell::Cell, - convert::TryInto, ffi::{CStr, OsStr, c_void}, os::{raw::c_char, unix::ffi::OsStrExt}, path::{Path, PathBuf}, @@ -58,7 +52,6 @@ use std::{ slice, str, sync::{Arc, OnceLock}, }; -use strum::IntoEnumIterator; use util::{ ResultExt, command::{new_smol_command, new_std_command}, @@ -164,9 +157,8 @@ pub(crate) struct MacPlatformState { text_system: Arc, renderer_context: renderer::Context, headless: bool, - pasteboard: id, - text_hash_pasteboard_type: id, - metadata_pasteboard_type: id, + general_pasteboard: Pasteboard, + find_pasteboard: Pasteboard, reopen: Option>, on_keyboard_layout_change: Option>, quit: Option>, @@ -206,9 +198,8 @@ impl MacPlatform { background_executor: BackgroundExecutor::new(dispatcher.clone()), foreground_executor: ForegroundExecutor::new(dispatcher), renderer_context: renderer::Context::default(), - pasteboard: unsafe { NSPasteboard::generalPasteboard(nil) }, - text_hash_pasteboard_type: unsafe { ns_string("zed-text-hash") }, - metadata_pasteboard_type: unsafe { ns_string("zed-metadata") }, + general_pasteboard: Pasteboard::general(), + find_pasteboard: Pasteboard::find(), reopen: None, quit: None, menu_command: None, @@ -224,20 +215,6 @@ impl MacPlatform { })) } - unsafe fn read_from_pasteboard(&self, pasteboard: *mut Object, kind: id) -> Option<&[u8]> { - unsafe { - let data = pasteboard.dataForType(kind); - if data == nil { - None - } else { - Some(slice::from_raw_parts( - data.bytes() as *mut u8, - data.length() as usize, - )) - } - } - } - unsafe fn create_menu_bar( &self, menus: &Vec, @@ -1034,119 +1011,24 @@ impl Platform for MacPlatform { } } - fn write_to_clipboard(&self, item: ClipboardItem) { - use crate::ClipboardEntry; - - unsafe { - // We only want to use NSAttributedString if there are multiple entries to write. - if item.entries.len() <= 1 { - match item.entries.first() { - Some(entry) => match entry { - ClipboardEntry::String(string) => { - self.write_plaintext_to_clipboard(string); - } - ClipboardEntry::Image(image) => { - self.write_image_to_clipboard(image); - } - ClipboardEntry::ExternalPaths(_) => {} - }, - None => { - // Writing an empty list of entries just clears the clipboard. - let state = self.0.lock(); - state.pasteboard.clearContents(); - } - } - } else { - let mut any_images = false; - let attributed_string = { - let mut buf = NSMutableAttributedString::alloc(nil) - // TODO can we skip this? Or at least part of it? - .init_attributed_string(ns_string("")) - .autorelease(); - - for entry in item.entries { - if let ClipboardEntry::String(ClipboardString { text, metadata: _ }) = entry - { - let to_append = NSAttributedString::alloc(nil) - .init_attributed_string(ns_string(&text)) - .autorelease(); - - buf.appendAttributedString_(to_append); - } - } - - buf - }; - - let state = self.0.lock(); - state.pasteboard.clearContents(); - - // Only set rich text clipboard types if we actually have 1+ images to include. - if any_images { - let rtfd_data = attributed_string.RTFDFromRange_documentAttributes_( - NSRange::new(0, msg_send![attributed_string, length]), - nil, - ); - if rtfd_data != nil { - state - .pasteboard - .setData_forType(rtfd_data, NSPasteboardTypeRTFD); - } - - let rtf_data = attributed_string.RTFFromRange_documentAttributes_( - NSRange::new(0, attributed_string.length()), - nil, - ); - if rtf_data != nil { - state - .pasteboard - .setData_forType(rtf_data, NSPasteboardTypeRTF); - } - } - - let plain_text = attributed_string.string(); - state - .pasteboard - .setString_forType(plain_text, NSPasteboardTypeString); - } - } - } - fn read_from_clipboard(&self) -> Option { let state = self.0.lock(); - let pasteboard = state.pasteboard; - - // First, see if it's a string. - unsafe { - let types: id = pasteboard.types(); - let string_type: id = ns_string("public.utf8-plain-text"); - - if msg_send![types, containsObject: string_type] { - let data = pasteboard.dataForType(string_type); - if data == nil { - return None; - } else if data.bytes().is_null() { - // https://developer.apple.com/documentation/foundation/nsdata/1410616-bytes?language=objc - // "If the length of the NSData object is 0, this property returns nil." - return Some(self.read_string_from_clipboard(&state, &[])); - } else { - let bytes = - slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize); + state.general_pasteboard.read() + } - return Some(self.read_string_from_clipboard(&state, bytes)); - } - } + fn write_to_clipboard(&self, item: ClipboardItem) { + let state = self.0.lock(); + state.general_pasteboard.write(item); + } - // If it wasn't a string, try the various supported image types. - for format in ImageFormat::iter() { - if let Some(item) = try_clipboard_image(pasteboard, format) { - return Some(item); - } - } - } + fn read_from_find_pasteboard(&self) -> Option { + let state = self.0.lock(); + state.find_pasteboard.read() + } - // If it wasn't a string or a supported image type, give up. - None + fn write_to_find_pasteboard(&self, item: ClipboardItem) { + let state = self.0.lock(); + state.find_pasteboard.write(item); } fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task> { @@ -1255,116 +1137,6 @@ impl Platform for MacPlatform { } } -impl MacPlatform { - unsafe fn read_string_from_clipboard( - &self, - state: &MacPlatformState, - text_bytes: &[u8], - ) -> ClipboardItem { - unsafe { - let text = String::from_utf8_lossy(text_bytes).to_string(); - let metadata = self - .read_from_pasteboard(state.pasteboard, state.text_hash_pasteboard_type) - .and_then(|hash_bytes| { - let hash_bytes = hash_bytes.try_into().ok()?; - let hash = u64::from_be_bytes(hash_bytes); - let metadata = self - .read_from_pasteboard(state.pasteboard, state.metadata_pasteboard_type)?; - - if hash == ClipboardString::text_hash(&text) { - String::from_utf8(metadata.to_vec()).ok() - } else { - None - } - }); - - ClipboardItem { - entries: vec![ClipboardEntry::String(ClipboardString { text, metadata })], - } - } - } - - unsafe fn write_plaintext_to_clipboard(&self, string: &ClipboardString) { - unsafe { - let state = self.0.lock(); - state.pasteboard.clearContents(); - - let text_bytes = NSData::dataWithBytes_length_( - nil, - string.text.as_ptr() as *const c_void, - string.text.len() as u64, - ); - state - .pasteboard - .setData_forType(text_bytes, NSPasteboardTypeString); - - if let Some(metadata) = string.metadata.as_ref() { - let hash_bytes = ClipboardString::text_hash(&string.text).to_be_bytes(); - let hash_bytes = NSData::dataWithBytes_length_( - nil, - hash_bytes.as_ptr() as *const c_void, - hash_bytes.len() as u64, - ); - state - .pasteboard - .setData_forType(hash_bytes, state.text_hash_pasteboard_type); - - let metadata_bytes = NSData::dataWithBytes_length_( - nil, - metadata.as_ptr() as *const c_void, - metadata.len() as u64, - ); - state - .pasteboard - .setData_forType(metadata_bytes, state.metadata_pasteboard_type); - } - } - } - - unsafe fn write_image_to_clipboard(&self, image: &Image) { - unsafe { - let state = self.0.lock(); - state.pasteboard.clearContents(); - - let bytes = NSData::dataWithBytes_length_( - nil, - image.bytes.as_ptr() as *const c_void, - image.bytes.len() as u64, - ); - - state - .pasteboard - .setData_forType(bytes, Into::::into(image.format).inner_mut()); - } - } -} - -fn try_clipboard_image(pasteboard: id, format: ImageFormat) -> Option { - let mut ut_type: UTType = format.into(); - - unsafe { - let types: id = pasteboard.types(); - if msg_send![types, containsObject: ut_type.inner()] { - let data = pasteboard.dataForType(ut_type.inner_mut()); - if data == nil { - None - } else { - let bytes = Vec::from(slice::from_raw_parts( - data.bytes() as *mut u8, - data.length() as usize, - )); - let id = hash(&bytes); - - Some(ClipboardItem { - entries: vec![ClipboardEntry::Image(Image { format, bytes, id })], - }) - } - } else { - None - } - } -} - unsafe fn path_from_objc(path: id) -> PathBuf { let len = msg_send![path, lengthOfBytesUsingEncoding: NSUTF8StringEncoding]; let bytes = unsafe { path.UTF8String() as *const u8 }; @@ -1605,120 +1377,3 @@ mod security { pub const errSecUserCanceled: OSStatus = -128; pub const errSecItemNotFound: OSStatus = -25300; } - -impl From for UTType { - fn from(value: ImageFormat) -> Self { - match value { - ImageFormat::Png => Self::png(), - ImageFormat::Jpeg => Self::jpeg(), - ImageFormat::Tiff => Self::tiff(), - ImageFormat::Webp => Self::webp(), - ImageFormat::Gif => Self::gif(), - ImageFormat::Bmp => Self::bmp(), - ImageFormat::Svg => Self::svg(), - ImageFormat::Ico => Self::ico(), - } - } -} - -// See https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ -struct UTType(id); - -impl UTType { - pub fn png() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/png - Self(unsafe { NSPasteboardTypePNG }) // This is a rare case where there's a built-in NSPasteboardType - } - - pub fn jpeg() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/jpeg - Self(unsafe { ns_string("public.jpeg") }) - } - - pub fn gif() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/gif - Self(unsafe { ns_string("com.compuserve.gif") }) - } - - pub fn webp() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/webp - Self(unsafe { ns_string("org.webmproject.webp") }) - } - - pub fn bmp() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/bmp - Self(unsafe { ns_string("com.microsoft.bmp") }) - } - - pub fn svg() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/svg - Self(unsafe { ns_string("public.svg-image") }) - } - - pub fn ico() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico - Self(unsafe { ns_string("com.microsoft.ico") }) - } - - pub fn tiff() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff - Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType - } - - fn inner(&self) -> *const Object { - self.0 - } - - fn inner_mut(&self) -> *mut Object { - self.0 as *mut _ - } -} - -#[cfg(test)] -mod tests { - use crate::ClipboardItem; - - use super::*; - - #[test] - fn test_clipboard() { - let platform = build_platform(); - assert_eq!(platform.read_from_clipboard(), None); - - let item = ClipboardItem::new_string("1".to_string()); - platform.write_to_clipboard(item.clone()); - assert_eq!(platform.read_from_clipboard(), Some(item)); - - let item = ClipboardItem { - entries: vec![ClipboardEntry::String( - ClipboardString::new("2".to_string()).with_json_metadata(vec![3, 4]), - )], - }; - platform.write_to_clipboard(item.clone()); - assert_eq!(platform.read_from_clipboard(), Some(item)); - - let text_from_other_app = "text from other app"; - unsafe { - let bytes = NSData::dataWithBytes_length_( - nil, - text_from_other_app.as_ptr() as *const c_void, - text_from_other_app.len() as u64, - ); - platform - .0 - .lock() - .pasteboard - .setData_forType(bytes, NSPasteboardTypeString); - } - assert_eq!( - platform.read_from_clipboard(), - Some(ClipboardItem::new_string(text_from_other_app.to_string())) - ); - } - - fn build_platform() -> MacPlatform { - let platform = MacPlatform::new(false); - platform.0.lock().pasteboard = unsafe { NSPasteboard::pasteboardWithUniqueName(nil) }; - platform - } -} diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index dfada364667989792325e02f8530e6c91bdf4716..ca9d5e2c3b7d405e40f208f5406f879467eafc5c 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -32,6 +32,8 @@ pub(crate) struct TestPlatform { current_clipboard_item: Mutex>, #[cfg(any(target_os = "linux", target_os = "freebsd"))] current_primary_item: Mutex>, + #[cfg(target_os = "macos")] + current_find_pasteboard_item: Mutex>, pub(crate) prompts: RefCell, screen_capture_sources: RefCell>, pub opened_url: RefCell>, @@ -117,6 +119,8 @@ impl TestPlatform { current_clipboard_item: Mutex::new(None), #[cfg(any(target_os = "linux", target_os = "freebsd"))] current_primary_item: Mutex::new(None), + #[cfg(target_os = "macos")] + current_find_pasteboard_item: Mutex::new(None), weak: weak.clone(), opened_url: Default::default(), #[cfg(target_os = "windows")] @@ -398,9 +402,8 @@ impl Platform for TestPlatform { false } - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - fn write_to_primary(&self, item: ClipboardItem) { - *self.current_primary_item.lock() = Some(item); + fn read_from_clipboard(&self) -> Option { + self.current_clipboard_item.lock().clone() } fn write_to_clipboard(&self, item: ClipboardItem) { @@ -412,8 +415,19 @@ impl Platform for TestPlatform { self.current_primary_item.lock().clone() } - fn read_from_clipboard(&self) -> Option { - self.current_clipboard_item.lock().clone() + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + fn write_to_primary(&self, item: ClipboardItem) { + *self.current_primary_item.lock() = Some(item); + } + + #[cfg(target_os = "macos")] + fn read_from_find_pasteboard(&self) -> Option { + self.current_find_pasteboard_item.lock().clone() + } + + #[cfg(target_os = "macos")] + fn write_to_find_pasteboard(&self, item: ClipboardItem) { + *self.current_find_pasteboard_item.lock() = Some(item); } fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task> { diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 12b283ab22937b7952d18d63b1378d2914211f9b..be3331048bc78a91a8d3c5a3637d6bf6ea007e4d 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -106,7 +106,10 @@ pub struct BufferSearchBar { replacement_editor_focused: bool, active_searchable_item: Option>, active_match_index: Option, - active_searchable_item_subscription: Option, + #[cfg(target_os = "macos")] + active_searchable_item_subscriptions: Option<[Subscription; 2]>, + #[cfg(not(target_os = "macos"))] + active_searchable_item_subscriptions: Option, active_search: Option>, searchable_items_with_matches: HashMap, AnyVec>, pending_search: Option>, @@ -472,7 +475,7 @@ impl ToolbarItemView for BufferSearchBar { cx: &mut Context, ) -> ToolbarItemLocation { cx.notify(); - self.active_searchable_item_subscription.take(); + self.active_searchable_item_subscriptions.take(); self.active_searchable_item.take(); self.pending_search.take(); @@ -482,18 +485,58 @@ impl ToolbarItemView for BufferSearchBar { { let this = cx.entity().downgrade(); - self.active_searchable_item_subscription = - Some(searchable_item_handle.subscribe_to_search_events( - window, - cx, - Box::new(move |search_event, window, cx| { - if let Some(this) = this.upgrade() { - this.update(cx, |this, cx| { - this.on_active_searchable_item_event(search_event, window, cx) - }); + let search_event_subscription = searchable_item_handle.subscribe_to_search_events( + window, + cx, + Box::new(move |search_event, window, cx| { + if let Some(this) = this.upgrade() { + this.update(cx, |this, cx| { + this.on_active_searchable_item_event(search_event, window, cx) + }); + } + }), + ); + + #[cfg(target_os = "macos")] + { + let item_focus_handle = searchable_item_handle.item_focus_handle(cx); + + self.active_searchable_item_subscriptions = Some([ + search_event_subscription, + cx.on_focus(&item_focus_handle, window, |this, window, cx| { + if this.query_editor_focused || this.replacement_editor_focused { + // no need to read pasteboard since focus came from toolbar + return; } + + cx.defer_in(window, |this, window, cx| { + if let Some(item) = cx.read_from_find_pasteboard() + && let Some(text) = item.text() + { + if this.query(cx) != text { + let search_options = item + .metadata() + .and_then(|m| m.parse().ok()) + .and_then(SearchOptions::from_bits) + .unwrap_or(this.search_options); + + drop(this.search( + &text, + Some(search_options), + true, + window, + cx, + )); + } + } + }); }), - )); + ]); + } + #[cfg(not(target_os = "macos"))] + { + self.active_searchable_item_subscriptions = Some(search_event_subscription); + } let is_project_search = searchable_item_handle.supported_options(cx).find_in_results; self.active_searchable_item = Some(searchable_item_handle); @@ -663,7 +706,7 @@ impl BufferSearchBar { replacement_editor, replacement_editor_focused: false, active_searchable_item: None, - active_searchable_item_subscription: None, + active_searchable_item_subscriptions: None, active_match_index: None, searchable_items_with_matches: Default::default(), default_options: search_options, @@ -904,11 +947,21 @@ impl BufferSearchBar { }); self.set_search_options(options, cx); self.clear_matches(window, cx); + #[cfg(target_os = "macos")] + self.update_find_pasteboard(cx); cx.notify(); } self.update_matches(!updated, add_to_history, window, cx) } + #[cfg(target_os = "macos")] + pub fn update_find_pasteboard(&mut self, cx: &mut App) { + cx.write_to_find_pasteboard(gpui::ClipboardItem::new_string_with_metadata( + self.query(cx), + self.search_options.bits().to_string(), + )); + } + pub fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context) { if let Some(active_editor) = self.active_searchable_item.as_ref() { let handle = active_editor.item_focus_handle(cx); @@ -1098,11 +1151,12 @@ impl BufferSearchBar { cx.spawn_in(window, async move |this, cx| { if search.await.is_ok() { this.update_in(cx, |this, window, cx| { - this.activate_current_match(window, cx) - }) - } else { - Ok(()) + this.activate_current_match(window, cx); + #[cfg(target_os = "macos")] + this.update_find_pasteboard(cx); + })?; } + anyhow::Ok(()) }) .detach_and_log_err(cx); } @@ -1293,6 +1347,7 @@ impl BufferSearchBar { .insert(active_searchable_item.downgrade(), matches); this.update_match_index(window, cx); + if add_to_history { this.search_history .add(&mut this.search_history_cursor, query_text); From 361b8e0ba9b11b68c456d43d03adabf53d7f54f0 Mon Sep 17 00:00:00 2001 From: Julia Ryan Date: Fri, 19 Dec 2025 09:04:15 -0800 Subject: [PATCH 17/19] Fix sticky header scroll offset (#45377) Closes #43319 Release Notes: - Sticky headers no longer obscure the cursor when it moves. --------- Co-authored-by: HactarCE <6060305+HactarCE@users.noreply.github.com> --- crates/editor/src/scroll/autoscroll.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/editor/src/scroll/autoscroll.rs b/crates/editor/src/scroll/autoscroll.rs index 28fd9442193bbec663d3f72eaa805214375dd8ca..fc2ecb9205109532da2b43c97821b5352f27aff2 100644 --- a/crates/editor/src/scroll/autoscroll.rs +++ b/crates/editor/src/scroll/autoscroll.rs @@ -5,7 +5,7 @@ use crate::{ }; use gpui::{Bounds, Context, Pixels, Window}; use language::Point; -use multi_buffer::Anchor; +use multi_buffer::{Anchor, ToPoint}; use std::cmp; #[derive(Debug, PartialEq, Eq, Clone, Copy)] @@ -186,6 +186,19 @@ impl Editor { } } + let style = self.style(cx).clone(); + let sticky_headers = self.sticky_headers(&style, cx).unwrap_or_default(); + let visible_sticky_headers = sticky_headers + .iter() + .filter(|h| { + let buffer_snapshot = display_map.buffer_snapshot(); + let buffer_range = + h.range.start.to_point(buffer_snapshot)..h.range.end.to_point(buffer_snapshot); + + buffer_range.contains(&Point::new(target_top as u32, 0)) + }) + .count(); + let margin = if matches!(self.mode, EditorMode::AutoHeight { .. }) { 0. } else { @@ -218,7 +231,7 @@ impl Editor { let was_autoscrolled = match strategy { AutoscrollStrategy::Fit | AutoscrollStrategy::Newest => { let margin = margin.min(self.scroll_manager.vertical_scroll_margin); - let target_top = (target_top - margin).max(0.0); + let target_top = (target_top - margin - visible_sticky_headers as f64).max(0.0); let target_bottom = target_bottom + margin; let start_row = scroll_position.y; let end_row = start_row + visible_lines; From bfe3c66c3e38b79b7c24f94e0dcf4cf781a6dbd5 Mon Sep 17 00:00:00 2001 From: morgankrey Date: Fri, 19 Dec 2025 11:19:12 -0600 Subject: [PATCH 18/19] docs: Automatic Documentation Github Action using Droid (#45374) Adds a multi-step agentic loop to github actions for opening a once-daily documentation PR that can be merged only be a Zedi Release Notes: - N/A --------- Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../prompts/docs-automation/phase2-explore.md | 55 +++ .../prompts/docs-automation/phase3-analyze.md | 57 +++ .../prompts/docs-automation/phase4-plan.md | 76 ++++ .../prompts/docs-automation/phase5-apply.md | 67 ++++ .../docs-automation/phase6-summarize.md | 54 +++ .../prompts/docs-automation/phase7-commit.md | 67 ++++ .github/workflows/docs_automation.yml | 256 +++++++++++++ docs/AGENTS.md | 353 ++++++++++++++++++ 8 files changed, 985 insertions(+) create mode 100644 .factory/prompts/docs-automation/phase2-explore.md create mode 100644 .factory/prompts/docs-automation/phase3-analyze.md create mode 100644 .factory/prompts/docs-automation/phase4-plan.md create mode 100644 .factory/prompts/docs-automation/phase5-apply.md create mode 100644 .factory/prompts/docs-automation/phase6-summarize.md create mode 100644 .factory/prompts/docs-automation/phase7-commit.md create mode 100644 .github/workflows/docs_automation.yml create mode 100644 docs/AGENTS.md diff --git a/.factory/prompts/docs-automation/phase2-explore.md b/.factory/prompts/docs-automation/phase2-explore.md new file mode 100644 index 0000000000000000000000000000000000000000..e8f0b1861912f9665d70e42dd02e1bb8a398b01e --- /dev/null +++ b/.factory/prompts/docs-automation/phase2-explore.md @@ -0,0 +1,55 @@ +# Phase 2: Explore Repository + +You are analyzing a codebase to understand its structure before reviewing documentation impact. + +## Objective +Produce a structured overview of the repository to inform subsequent documentation analysis. + +## Instructions + +1. **Identify Primary Languages and Frameworks** + - Scan for Cargo.toml, package.json, or other manifest files + - Note the primary language(s) and key dependencies + +2. **Map Documentation Structure** + - This project uses **mdBook** (https://rust-lang.github.io/mdBook/) + - Documentation is in `docs/src/` + - Table of contents: `docs/src/SUMMARY.md` (mdBook format: https://rust-lang.github.io/mdBook/format/summary.html) + - Style guide: `docs/.rules` + - Agent guidelines: `docs/AGENTS.md` + - Formatting: Prettier (config in `docs/.prettierrc`) + +3. **Identify Build and Tooling** + - Note build systems (cargo, npm, etc.) + - Identify documentation tooling (mdbook, etc.) + +4. **Output Format** +Produce a JSON summary: + +```json +{ + "primary_language": "Rust", + "frameworks": ["GPUI"], + "documentation": { + "system": "mdBook", + "location": "docs/src/", + "toc_file": "docs/src/SUMMARY.md", + "toc_format": "https://rust-lang.github.io/mdBook/format/summary.html", + "style_guide": "docs/.rules", + "agent_guidelines": "docs/AGENTS.md", + "formatter": "prettier", + "formatter_config": "docs/.prettierrc", + "custom_preprocessor": "docs_preprocessor (handles {#kb action::Name} syntax)" + }, + "key_directories": { + "source": "crates/", + "docs": "docs/src/", + "extensions": "extensions/" + } +} +``` + +## Constraints +- Read-only: Do not modify any files +- Focus on structure, not content details +- Complete within 2 minutes diff --git a/.factory/prompts/docs-automation/phase3-analyze.md b/.factory/prompts/docs-automation/phase3-analyze.md new file mode 100644 index 0000000000000000000000000000000000000000..8fc8622434d3be2e6be7997e9773c1b2435202c6 --- /dev/null +++ b/.factory/prompts/docs-automation/phase3-analyze.md @@ -0,0 +1,57 @@ +# Phase 3: Analyze Changes + +You are analyzing code changes to understand their nature and scope. + +## Objective +Produce a clear, neutral summary of what changed in the codebase. + +## Input +You will receive: +- List of changed files from the triggering commit/PR +- Repository structure from Phase 2 + +## Instructions + +1. **Categorize Changed Files** + - Source code (which crates/modules) + - Configuration + - Tests + - Documentation (already existing) + - Other + +2. **Analyze Each Change** + - Review diffs for files likely to impact documentation + - Focus on: public APIs, settings, keybindings, commands, user-visible behavior + +3. **Identify What Did NOT Change** + - Note stable interfaces or behaviors + - Important for avoiding unnecessary documentation updates + +4. **Output Format** +Produce a markdown summary: + +```markdown +## Change Analysis + +### Changed Files Summary +| Category | Files | Impact Level | +| --- | --- | --- | +| Source - [crate] | file1.rs, file2.rs | High/Medium/Low | +| Settings | settings.json | Medium | +| Tests | test_*.rs | None | + +### Behavioral Changes +- **[Feature/Area]**: Description of what changed from user perspective +- **[Feature/Area]**: Description... + +### Unchanged Areas +- [Area]: Confirmed no changes to [specific behavior] + +### Files Requiring Deeper Review +- `path/to/file.rs`: Reason for deeper review +``` + +## Constraints +- Read-only: Do not modify any files +- Neutral tone: Describe what changed, not whether it's good/bad +- Do not propose documentation changes yet diff --git a/.factory/prompts/docs-automation/phase4-plan.md b/.factory/prompts/docs-automation/phase4-plan.md new file mode 100644 index 0000000000000000000000000000000000000000..9e6a15814e7813cbf27afb0413987afa539feaf6 --- /dev/null +++ b/.factory/prompts/docs-automation/phase4-plan.md @@ -0,0 +1,76 @@ +# Phase 4: Plan Documentation Impact + +You are determining whether and how documentation should be updated based on code changes. + +## Objective +Produce a structured documentation plan that will guide Phase 5 execution. + +## Documentation System +This is an **mdBook** site (https://rust-lang.github.io/mdBook/): +- `docs/src/SUMMARY.md` defines book structure per https://rust-lang.github.io/mdBook/format/summary.html +- If adding new pages, they MUST be added to SUMMARY.md +- Use `{#kb action::ActionName}` syntax for keybindings (custom preprocessor expands these) +- Prettier formatting (80 char width) will be applied automatically + +## Input +You will receive: +- Change analysis from Phase 3 +- Repository structure from Phase 2 +- Documentation guidelines from `docs/AGENTS.md` + +## Instructions + +1. **Review AGENTS.md** + - Load and apply all rules from `docs/AGENTS.md` + - Respect scope boundaries (in-scope vs out-of-scope) + +2. **Evaluate Documentation Impact** + For each behavioral change from Phase 3: + - Does existing documentation cover this area? + - Is the documentation now inaccurate or incomplete? + - Classify per AGENTS.md "Change Classification" section + +3. **Identify Specific Updates** + For each required update: + - Exact file path + - Specific section or heading + - Type of change (update existing, add new, deprecate) + - Description of the change + +4. **Flag Uncertainty** + Explicitly mark: + - Assumptions you're making + - Areas where human confirmation is needed + - Ambiguous requirements + +5. **Output Format** +Use the exact format specified in `docs/AGENTS.md` Phase 4 section: + +```markdown +## Documentation Impact Assessment + +### Summary +Brief description of code changes analyzed. + +### Documentation Updates Required: [Yes/No] + +### Planned Changes + +#### 1. [File Path] +- **Section**: [Section name or "New section"] +- **Change Type**: [Update/Add/Deprecate] +- **Reason**: Why this change is needed +- **Description**: What will be added/modified + +### Uncertainty Flags +- [ ] [Description of any assumptions or areas needing confirmation] + +### No Changes Needed +- [List files reviewed but not requiring updates, with brief reason] +``` + +## Constraints +- Read-only: Do not modify any files +- Conservative: When uncertain, flag for human review rather than planning changes +- Scoped: Only plan changes that trace directly to code changes from Phase 3 +- No scope expansion: Do not plan "improvements" unrelated to triggering changes diff --git a/.factory/prompts/docs-automation/phase5-apply.md b/.factory/prompts/docs-automation/phase5-apply.md new file mode 100644 index 0000000000000000000000000000000000000000..9cc63071fccf880443b729baa06f0ddbd769276b --- /dev/null +++ b/.factory/prompts/docs-automation/phase5-apply.md @@ -0,0 +1,67 @@ +# Phase 5: Apply Documentation Plan + +You are executing a pre-approved documentation plan for an **mdBook** documentation site. + +## Objective +Implement exactly the changes specified in the documentation plan from Phase 4. + +## Documentation System +- **mdBook**: https://rust-lang.github.io/mdBook/ +- **SUMMARY.md**: Follows mdBook format (https://rust-lang.github.io/mdBook/format/summary.html) +- **Prettier**: Will be run automatically after this phase (80 char line width) +- **Custom preprocessor**: Use `{#kb action::ActionName}` for keybindings instead of hardcoding + +## Input +You will receive: +- Documentation plan from Phase 4 +- Documentation guidelines from `docs/AGENTS.md` +- Style rules from `docs/.rules` + +## Instructions + +1. **Validate Plan** + - Confirm all planned files are within scope per AGENTS.md + - Verify no out-of-scope files are targeted + +2. **Execute Each Planned Change** + For each item in "Planned Changes": + - Navigate to the specified file + - Locate the specified section + - Apply the described change + - Follow style rules from `docs/.rules` + +3. **Style Compliance** + Every edit must follow `docs/.rules`: + - Second person, present tense + - No hedging words ("simply", "just", "easily") + - Proper keybinding format (`Cmd+Shift+P`) + - Settings Editor first, JSON second + - Correct terminology (folder not directory, etc.) + +4. **Preserve Context** + - Maintain surrounding content structure + - Keep consistent heading levels + - Preserve existing cross-references + +## Constraints +- Execute ONLY changes listed in the plan +- Do not discover new documentation targets +- Do not make stylistic improvements outside planned sections +- Do not expand scope beyond what Phase 4 specified +- If a planned change cannot be applied (file missing, section not found), skip and note it + +## Output +After applying changes, output a summary: + +```markdown +## Applied Changes + +### Successfully Applied +- `path/to/file.md`: [Brief description of change] + +### Skipped (Could Not Apply) +- `path/to/file.md`: [Reason - e.g., "Section not found"] + +### Warnings +- [Any issues encountered during application] +``` diff --git a/.factory/prompts/docs-automation/phase6-summarize.md b/.factory/prompts/docs-automation/phase6-summarize.md new file mode 100644 index 0000000000000000000000000000000000000000..b1480ac9431702539fa2a570c2b456bcdfae46af --- /dev/null +++ b/.factory/prompts/docs-automation/phase6-summarize.md @@ -0,0 +1,54 @@ +# Phase 6: Summarize Changes + +You are generating a summary of documentation updates for PR review. + +## Objective +Create a clear, reviewable summary of all documentation changes made. + +## Input +You will receive: +- Applied changes report from Phase 5 +- Original change analysis from Phase 3 +- Git diff of documentation changes + +## Instructions + +1. **Gather Change Information** + - List all modified documentation files + - Identify the corresponding code changes that triggered each update + +2. **Generate Summary** + Use the format specified in `docs/AGENTS.md` Phase 6 section: + +```markdown +## Documentation Update Summary + +### Changes Made +| File | Change | Related Code | +| --- | --- | --- | +| docs/src/path.md | Brief description | PR #123 or commit SHA | + +### Rationale +Brief explanation of why these updates were made, linking back to the triggering code changes. + +### Review Notes +- Items reviewers should pay special attention to +- Any uncertainty flags from Phase 4 that were addressed +- Assumptions made during documentation +``` + +3. **Add Context for Reviewers** + - Highlight any changes that might be controversial + - Note if any planned changes were skipped and why + - Flag areas where reviewer expertise is especially needed + +## Output Format +The summary should be suitable for: +- PR description body +- Commit message (condensed version) +- Team communication + +## Constraints +- Read-only (documentation changes already applied in Phase 5) +- Factual: Describe what was done, not justify why it's good +- Complete: Account for all changes, including skipped items diff --git a/.factory/prompts/docs-automation/phase7-commit.md b/.factory/prompts/docs-automation/phase7-commit.md new file mode 100644 index 0000000000000000000000000000000000000000..adfd92eec7d3058af3917f2663228b4f6ee5c445 --- /dev/null +++ b/.factory/prompts/docs-automation/phase7-commit.md @@ -0,0 +1,67 @@ +# Phase 7: Commit and Open PR + +You are creating a git branch, committing documentation changes, and opening a PR. + +## Objective +Package documentation updates into a reviewable pull request. + +## Input +You will receive: +- Summary from Phase 6 +- List of modified files + +## Instructions + +1. **Create Branch** + ```sh + git checkout -b docs/auto-update-{date} + ``` + Use format: `docs/auto-update-YYYY-MM-DD` or `docs/auto-update-{short-sha}` + +2. **Stage and Commit** + - Stage only documentation files in `docs/src/` + - Do not stage any other files + + Commit message format: + ``` + docs: auto-update documentation for [brief description] + + [Summary from Phase 6, condensed] + + Triggered by: [commit SHA or PR reference] + + Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> + ``` + +3. **Push Branch** + ```sh + git push -u origin docs/auto-update-{date} + ``` + +4. **Create Pull Request** + Use the Phase 6 summary as the PR body. + + PR Title: `docs: [Brief description of documentation updates]` + + Labels (if available): `documentation`, `automated` + + Base branch: `main` + +## Constraints +- Do NOT auto-merge +- Do NOT request specific reviewers (let CODEOWNERS handle it) +- Do NOT modify files outside `docs/src/` +- If no changes to commit, exit gracefully with message "No documentation changes to commit" + +## Output +```markdown +## PR Created + +- **Branch**: docs/auto-update-{date} +- **PR URL**: https://github.com/zed-industries/zed/pull/XXXX +- **Status**: Ready for review + +### Commit +- SHA: {commit-sha} +- Files: {count} documentation files modified +``` diff --git a/.github/workflows/docs_automation.yml b/.github/workflows/docs_automation.yml new file mode 100644 index 0000000000000000000000000000000000000000..e4aa79c7fc09d6d7735ac82e2315d68b923d5323 --- /dev/null +++ b/.github/workflows/docs_automation.yml @@ -0,0 +1,256 @@ +name: Documentation Automation + +on: + push: + branches: [main] + paths: + - 'crates/**' + - 'extensions/**' + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to analyze (gets full PR diff)' + required: false + type: string + trigger_sha: + description: 'Commit SHA to analyze (ignored if pr_number is set)' + required: false + type: string + +permissions: + contents: write + pull-requests: write + +env: + FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} + DROID_MODEL: claude-opus-4-5 + +jobs: + docs-automation: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Droid CLI + run: | + curl -fsSL https://cli.factory.ai/install.sh | bash + echo "${HOME}/.factory/bin" >> "$GITHUB_PATH" + + - name: Setup Node.js (for Prettier) + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Prettier + run: npm install -g prettier + + - name: Get changed files + id: changed + run: | + if [ -n "${{ inputs.pr_number }}" ]; then + # Get full PR diff + echo "Analyzing PR #${{ inputs.pr_number }}" + echo "source=pr" >> "$GITHUB_OUTPUT" + echo "ref=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT" + gh pr diff "${{ inputs.pr_number }}" --name-only > /tmp/changed_files.txt + elif [ -n "${{ inputs.trigger_sha }}" ]; then + # Get single commit diff + SHA="${{ inputs.trigger_sha }}" + echo "Analyzing commit $SHA" + echo "source=commit" >> "$GITHUB_OUTPUT" + echo "ref=$SHA" >> "$GITHUB_OUTPUT" + git diff --name-only "${SHA}^" "$SHA" > /tmp/changed_files.txt + else + # Default to current commit + SHA="${{ github.sha }}" + echo "Analyzing commit $SHA" + echo "source=commit" >> "$GITHUB_OUTPUT" + echo "ref=$SHA" >> "$GITHUB_OUTPUT" + git diff --name-only "${SHA}^" "$SHA" > /tmp/changed_files.txt || git diff --name-only HEAD~1 HEAD > /tmp/changed_files.txt + fi + + echo "Changed files:" + cat /tmp/changed_files.txt + env: + GH_TOKEN: ${{ github.token }} + + # Phase 0: Guardrails are loaded via AGENTS.md in each phase + + # Phase 2: Explore Repository (Read-Only) + - name: "Phase 2: Explore Repository" + id: phase2 + run: | + droid exec \ + --model "$DROID_MODEL" \ + --autonomy read-only \ + --prompt-file .factory/prompts/docs-automation/phase2-explore.md \ + --output /tmp/phase2-output.json \ + --format json + echo "Repository exploration complete" + cat /tmp/phase2-output.json + + # Phase 3: Analyze Changes (Read-Only) + - name: "Phase 3: Analyze Changes" + id: phase3 + run: | + CHANGED_FILES=$(tr '\n' ' ' < /tmp/changed_files.txt) + droid exec \ + --model "$DROID_MODEL" \ + --autonomy read-only \ + --prompt-file .factory/prompts/docs-automation/phase3-analyze.md \ + --context "Changed files: $CHANGED_FILES" \ + --context-file /tmp/phase2-output.json \ + --output /tmp/phase3-output.md \ + --format markdown + echo "Change analysis complete" + cat /tmp/phase3-output.md + + # Phase 4: Plan Documentation Impact (Read-Only) + - name: "Phase 4: Plan Documentation Impact" + id: phase4 + run: | + droid exec \ + --model "$DROID_MODEL" \ + --autonomy read-only \ + --prompt-file .factory/prompts/docs-automation/phase4-plan.md \ + --context-file /tmp/phase3-output.md \ + --context-file docs/AGENTS.md \ + --output /tmp/phase4-plan.md \ + --format markdown + echo "Documentation plan complete" + cat /tmp/phase4-plan.md + + # Check if updates are required + if grep -q "Documentation Updates Required: No" /tmp/phase4-plan.md; then + echo "updates_required=false" >> "$GITHUB_OUTPUT" + else + echo "updates_required=true" >> "$GITHUB_OUTPUT" + fi + + # Phase 5: Apply Plan (Write-Enabled) + - name: "Phase 5: Apply Documentation Plan" + id: phase5 + if: steps.phase4.outputs.updates_required == 'true' + run: | + droid exec \ + --model "$DROID_MODEL" \ + --autonomy medium \ + --prompt-file .factory/prompts/docs-automation/phase5-apply.md \ + --context-file /tmp/phase4-plan.md \ + --context-file docs/AGENTS.md \ + --context-file docs/.rules \ + --output /tmp/phase5-report.md \ + --format markdown + echo "Documentation updates applied" + cat /tmp/phase5-report.md + + # Phase 5b: Format with Prettier + - name: "Phase 5b: Format with Prettier" + id: phase5b + if: steps.phase4.outputs.updates_required == 'true' + run: | + echo "Formatting documentation with Prettier..." + cd docs && prettier --write src/ + + echo "Verifying Prettier formatting passes..." + cd docs && prettier --check src/ + + echo "Prettier formatting complete" + + # Phase 6: Summarize Changes + - name: "Phase 6: Summarize Changes" + id: phase6 + if: steps.phase4.outputs.updates_required == 'true' + run: | + # Get git diff of docs + git diff docs/src/ > /tmp/docs-diff.txt || true + + droid exec \ + --model "$DROID_MODEL" \ + --autonomy read-only \ + --prompt-file .factory/prompts/docs-automation/phase6-summarize.md \ + --context-file /tmp/phase5-report.md \ + --context-file /tmp/phase3-output.md \ + --context "Trigger SHA: ${{ steps.changed.outputs.sha }}" \ + --output /tmp/phase6-summary.md \ + --format markdown + echo "Summary generated" + cat /tmp/phase6-summary.md + + # Phase 7: Commit and Open PR + - name: "Phase 7: Create PR" + id: phase7 + if: steps.phase4.outputs.updates_required == 'true' + run: | + # Check if there are actual changes + if git diff --quiet docs/src/; then + echo "No documentation changes detected" + exit 0 + fi + + # Configure git + git config user.name "factory-droid[bot]" + git config user.email "138933559+factory-droid[bot]@users.noreply.github.com" + + # Daily batch branch - one branch per day, multiple commits accumulate + BRANCH_NAME="docs/auto-update-$(date +%Y-%m-%d)" + + # Check if branch already exists on remote + if git ls-remote --exit-code --heads origin "$BRANCH_NAME" > /dev/null 2>&1; then + echo "Branch $BRANCH_NAME exists, checking out and updating..." + git fetch origin "$BRANCH_NAME" + git checkout -B "$BRANCH_NAME" "origin/$BRANCH_NAME" + else + echo "Creating new branch $BRANCH_NAME..." + git checkout -b "$BRANCH_NAME" + fi + + # Stage and commit + git add docs/src/ + SUMMARY=$(head -50 < /tmp/phase6-summary.md) + git commit -m "docs: auto-update documentation + + ${SUMMARY} + + Triggered by: ${{ steps.changed.outputs.source }} ${{ steps.changed.outputs.ref }} + + Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>" + + # Push + git push -u origin "$BRANCH_NAME" + + # Check if PR already exists for this branch + EXISTING_PR=$(gh pr list --head "$BRANCH_NAME" --json number --jq '.[0].number' || echo "") + + if [ -n "$EXISTING_PR" ]; then + echo "PR #$EXISTING_PR already exists for branch $BRANCH_NAME, updated with new commit" + else + # Create new PR + gh pr create \ + --title "docs: automated documentation update ($(date +%Y-%m-%d))" \ + --body-file /tmp/phase6-summary.md \ + --base main || true + echo "PR created on branch: $BRANCH_NAME" + fi + env: + GH_TOKEN: ${{ github.token }} + + # Summary output + - name: "Summary" + if: always() + run: | + echo "## Documentation Automation Summary" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + if [ "${{ steps.phase4.outputs.updates_required }}" == "false" ]; then + echo "No documentation updates required for this change." >> "$GITHUB_STEP_SUMMARY" + elif [ -f /tmp/phase6-summary.md ]; then + cat /tmp/phase6-summary.md >> "$GITHUB_STEP_SUMMARY" + else + echo "Workflow completed. Check individual phase outputs for details." >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..fdd61ff6aeaf8cd09ae0b017c5199e7033fba964 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,353 @@ +# Documentation Automation Agent Guidelines + +This file governs automated documentation updates triggered by code changes. All automation phases must comply with these rules. + +## Documentation System + +This documentation uses **mdBook** (https://rust-lang.github.io/mdBook/). + +### Key Files + +- **`docs/src/SUMMARY.md`**: Table of contents following mdBook format (https://rust-lang.github.io/mdBook/format/summary.html) +- **`docs/book.toml`**: mdBook configuration +- **`docs/.prettierrc`**: Prettier config (80 char line width) + +### SUMMARY.md Format + +The `SUMMARY.md` file defines the book structure. Format rules: + +- Chapter titles are links: `[Title](./path/to/file.md)` +- Nesting via indentation (2 spaces per level) +- Separators: `---` for horizontal rules between sections +- Draft chapters: `[Title]()` (empty parens, not yet written) + +Example: + +```markdown +# Section Title + +- [Chapter](./chapter.md) + - [Nested Chapter](./nested.md) + +--- + +# Another Section +``` + +### Custom Preprocessor + +The docs use a custom preprocessor (`docs_preprocessor`) that expands special commands: + +| Syntax | Purpose | Example | +| ----------------------------- | ------------------------------------- | ------------------------------- | +| `{#kb action::ActionName}` | Keybinding for action | `{#kb agent::ToggleFocus}` | +| `{#action agent::ActionName}` | Action reference (renders as command) | `{#action agent::OpenSettings}` | + +**Rules:** + +- Always use preprocessor syntax for keybindings instead of hardcoding +- Action names use `snake_case` in the namespace, `PascalCase` for the action +- Common namespaces: `agent::`, `editor::`, `assistant::`, `vim::` + +### Formatting Requirements + +All documentation must pass **Prettier** formatting: + +```sh +cd docs && npx prettier --check src/ +``` + +Before any documentation change is considered complete: + +1. Run Prettier to format: `cd docs && npx prettier --write src/` +2. Verify it passes: `cd docs && npx prettier --check src/` + +Prettier config: 80 character line width (`docs/.prettierrc`) + +### Section Anchors + +Use `{#anchor-id}` syntax for linkable section headers: + +```markdown +## Getting Started {#getting-started} + +### Custom Models {#anthropic-custom-models} +``` + +Anchor IDs should be: + +- Lowercase with hyphens +- Unique within the page +- Descriptive (can include parent context like `anthropic-custom-models`) + +### Code Block Annotations + +Use annotations after the language identifier to indicate file context: + +```markdown +\`\`\`json [settings] +{ +"agent": { ... } +} +\`\`\` + +\`\`\`json [keymap] +[ +{ "bindings": { ... } } +] +\`\`\` +``` + +Valid annotations: `[settings]` (for settings.json), `[keymap]` (for keymap.json) + +### Blockquote Formatting + +Use bold labels for callouts: + +```markdown +> **Note:** Important information the user should know. + +> **Tip:** Helpful advice that saves time or improves workflow. + +> **Warn:** Caution about potential issues or gotchas. +``` + +### Image References + +Images are hosted externally. Reference format: + +```markdown +![Alt text description](https://zed.dev/img/path/to/image.webp) +``` + +### Cross-Linking + +- Relative links for same-directory: `[Agent Panel](./agent-panel.md)` +- With anchors: `[Custom Models](./llm-providers.md#anthropic-custom-models)` +- Parent directory: `[Telemetry](../telemetry.md)` + +## Scope + +### In-Scope Documentation + +- All Markdown files in `docs/src/` +- `docs/src/SUMMARY.md` (mdBook table of contents) +- Language-specific docs in `docs/src/languages/` +- Feature docs (AI, extensions, configuration, etc.) + +### Out-of-Scope (Do Not Modify) + +- `CHANGELOG.md`, `CONTRIBUTING.md`, `README.md` at repo root +- Inline code comments and rustdoc +- `CLAUDE.md`, `GEMINI.md`, or other AI instruction files +- Build configuration (`book.toml`, theme files, `docs_preprocessor`) +- Any file outside `docs/src/` + +## Page Structure Patterns + +### Standard Page Layout + +Most documentation pages follow this structure: + +1. **Title** (H1) - Single sentence or phrase +2. **Overview/Introduction** - 1-3 paragraphs explaining what this is +3. **Getting Started** `{#getting-started}` - Prerequisites and first steps +4. **Main Content** - Feature details, organized by topic +5. **Advanced/Configuration** - Power user options +6. **See Also** (optional) - Related documentation links + +### Settings Documentation Pattern + +When documenting settings: + +1. Show the Settings Editor (UI) approach first +2. Then show JSON as "Or add this to your settings.json:" +3. Always show complete, valid JSON with surrounding structure: + +```json [settings] +{ + "agent": { + "default_model": { + "provider": "anthropic", + "model": "claude-sonnet-4" + } + } +} +``` + +### Provider/Feature Documentation Pattern + +For each provider or distinct feature: + +1. H3 heading with anchor: `### Provider Name {#provider-name}` +2. Brief description (1-2 sentences) +3. Setup steps (numbered list) +4. Configuration example (JSON code block) +5. Custom models section if applicable: `#### Custom Models {#provider-custom-models}` + +## Style Rules + +Inherit all conventions from `docs/.rules`. Key points: + +### Voice + +- Second person ("you"), present tense +- Direct and concise—no hedging ("simply", "just", "easily") +- Honest about limitations; no promotional language + +### Formatting + +- Keybindings: backticks with `+` for simultaneous keys (`Cmd+Shift+P`) +- Show both macOS and Linux/Windows variants when they differ +- Use `sh` code blocks for terminal commands +- Settings: show Settings Editor UI first, JSON as secondary + +### Terminology + +| Use | Instead of | +| --------------- | -------------------------------------- | +| folder | directory | +| project | workspace | +| Settings Editor | settings UI | +| command palette | command bar | +| panel | sidebar (be specific: "Project Panel") | + +## Zed-Specific Conventions + +### Recognized Rules Files + +When documenting rules/instructions for AI, note that Zed recognizes these files (in priority order): + +- `.rules` +- `.cursorrules` +- `.windsurfrules` +- `.clinerules` +- `.github/copilot-instructions.md` +- `AGENT.md` +- `AGENTS.md` +- `CLAUDE.md` +- `GEMINI.md` + +### Settings File Locations + +- macOS: `~/.config/zed/settings.json` +- Linux: `~/.config/zed/settings.json` +- Windows: `%AppData%\Zed\settings.json` + +### Keymap File Locations + +- macOS: `~/.config/zed/keymap.json` +- Linux: `~/.config/zed/keymap.json` +- Windows: `%AppData%\Zed\keymap.json` + +## Safety Constraints + +### Must Not + +- Delete existing documentation files +- Remove sections documenting existing functionality +- Change URLs or anchor links without verifying references +- Modify `SUMMARY.md` structure without corresponding content +- Add speculative documentation for unreleased features +- Include internal implementation details not relevant to users + +### Must + +- Preserve existing structure when updating content +- Maintain backward compatibility of documented settings/commands +- Flag uncertainty explicitly rather than guessing +- Link to related documentation when adding new sections + +## Change Classification + +### Requires Documentation Update + +- New user-facing features or commands +- Changed keybindings or default behaviors +- Modified settings schema or options +- Deprecated or removed functionality +- API changes affecting extensions + +### Does Not Require Documentation Update + +- Internal refactoring without behavioral changes +- Performance optimizations (unless user-visible) +- Bug fixes that restore documented behavior +- Test changes +- CI/CD changes + +## Output Format + +### Phase 4 Documentation Plan + +When generating a documentation plan, use this structure: + +```markdown +## Documentation Impact Assessment + +### Summary + +Brief description of code changes analyzed. + +### Documentation Updates Required: [Yes/No] + +### Planned Changes + +#### 1. [File Path] + +- **Section**: [Section name or "New section"] +- **Change Type**: [Update/Add/Deprecate] +- **Reason**: Why this change is needed +- **Description**: What will be added/modified + +#### 2. [File Path] + +... + +### Uncertainty Flags + +- [ ] [Description of any assumptions or areas needing confirmation] + +### No Changes Needed + +- [List files reviewed but not requiring updates, with brief reason] +``` + +### Phase 6 Summary Format + +```markdown +## Documentation Update Summary + +### Changes Made + +| File | Change | Related Code | +| -------------- | ----------------- | ----------------- | +| path/to/doc.md | Brief description | link to PR/commit | + +### Rationale + +Brief explanation of why these updates were made. + +### Review Notes + +Any items reviewers should pay special attention to. +``` + +## Behavioral Guidelines + +### Conservative by Default + +- When uncertain whether to document something, flag it for human review +- Prefer smaller, focused updates over broad rewrites +- Do not "improve" documentation unrelated to the triggering code change + +### Traceability + +- Every documentation change should trace to a specific code change +- Include references to relevant commits, PRs, or issues in summaries + +### Incremental Updates + +- Update existing sections rather than creating parallel documentation +- Maintain consistency with surrounding content +- Follow the established patterns in each documentation area From 4ef5d2c8148e555388668b094e059633c5bc405a Mon Sep 17 00:00:00 2001 From: Andrew Farkas <6060305+HactarCE@users.noreply.github.com> Date: Fri, 19 Dec 2025 12:32:38 -0500 Subject: [PATCH 19/19] Fix relative line numbers in sticky headers (#45164) Closes #42586 This includes a rewrite of `calculate_relative_line_numbers()`. Now it's linear-time with respect to the number of rows displayed, instead of linear time with respect to the number of rows displayed _plus_ the distance to the base row. Release Notes: - Improved performance when using relative line numbers in large files - Fixed relative line numbers not appearing in sticky headers --- crates/editor/src/editor.rs | 66 ++++++++++- crates/editor/src/editor_tests.rs | 127 ++++++++++++++++++++- crates/editor/src/element.rs | 176 ++++++++++++------------------ 3 files changed, 258 insertions(+), 111 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 6e4744335b8e9fba50a6c2c8b241607b0e05d276..d985a4d269f2eaeb3fa6056192095b7913b579b6 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -20475,7 +20475,7 @@ impl Editor { EditorSettings::get_global(cx).gutter.line_numbers } - pub fn relative_line_numbers(&self, cx: &mut App) -> RelativeLineNumbers { + pub fn relative_line_numbers(&self, cx: &App) -> RelativeLineNumbers { match ( self.use_relative_line_numbers, EditorSettings::get_global(cx).relative_line_numbers, @@ -25294,6 +25294,70 @@ impl EditorSnapshot { let digit_count = self.widest_line_number().ilog10() + 1; column_pixels(style, digit_count as usize, window) } + + /// Returns the line delta from `base` to `line` in the multibuffer, ignoring wrapped lines. + /// + /// This is positive if `base` is before `line`. + fn relative_line_delta(&self, base: DisplayRow, line: DisplayRow) -> i64 { + let point = DisplayPoint::new(line, 0).to_point(self); + self.relative_line_delta_to_point(base, point) + } + + /// Returns the line delta from `base` to `point` in the multibuffer, ignoring wrapped lines. + /// + /// This is positive if `base` is before `point`. + pub fn relative_line_delta_to_point(&self, base: DisplayRow, point: Point) -> i64 { + let base_point = DisplayPoint::new(base, 0).to_point(self); + point.row as i64 - base_point.row as i64 + } + + /// Returns the line delta from `base` to `line` in the multibuffer, counting wrapped lines. + /// + /// This is positive if `base` is before `line`. + fn relative_wrapped_line_delta(&self, base: DisplayRow, line: DisplayRow) -> i64 { + let point = DisplayPoint::new(line, 0).to_point(self); + self.relative_wrapped_line_delta_to_point(base, point) + } + + /// Returns the line delta from `base` to `point` in the multibuffer, counting wrapped lines. + /// + /// This is positive if `base` is before `point`. + pub fn relative_wrapped_line_delta_to_point(&self, base: DisplayRow, point: Point) -> i64 { + let base_point = DisplayPoint::new(base, 0).to_point(self); + let wrap_snapshot = self.wrap_snapshot(); + let base_wrap_row = wrap_snapshot.make_wrap_point(base_point, Bias::Left).row(); + let wrap_row = wrap_snapshot.make_wrap_point(point, Bias::Left).row(); + wrap_row.0 as i64 - base_wrap_row.0 as i64 + } + + /// Returns the unsigned relative line number to display for each row in `rows`. + /// + /// Wrapped rows are excluded from the hashmap if `count_relative_lines` is `false`. + pub fn calculate_relative_line_numbers( + &self, + rows: &Range, + relative_to: DisplayRow, + count_wrapped_lines: bool, + ) -> HashMap { + let initial_offset = if count_wrapped_lines { + self.relative_wrapped_line_delta(relative_to, rows.start) + } else { + self.relative_line_delta(relative_to, rows.start) + }; + let display_row_infos = self + .row_infos(rows.start) + .take(rows.len()) + .enumerate() + .map(|(i, row_info)| (DisplayRow(rows.start.0 + i as u32), row_info)); + display_row_infos + .filter(|(_row, row_info)| { + row_info.buffer_row.is_some() + || (count_wrapped_lines && row_info.wrapped_buffer_row.is_some()) + }) + .enumerate() + .map(|(i, (row, _row_info))| (row, (initial_offset + i as i64).unsigned_abs() as u32)) + .collect() + } } pub fn column_pixels(style: &EditorStyle, column: usize, window: &Window) -> Pixels { diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 87674d8c507b1c294779b1f9ddba458320fc7671..613850428a8720ed37efa447a1312c262a05571a 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -36,7 +36,8 @@ use languages::markdown_lang; use languages::rust_lang; use lsp::CompletionParams; use multi_buffer::{ - IndentGuide, MultiBufferFilterMode, MultiBufferOffset, MultiBufferOffsetUtf16, PathKey, + ExcerptRange, IndentGuide, MultiBuffer, MultiBufferFilterMode, MultiBufferOffset, + MultiBufferOffsetUtf16, PathKey, }; use parking_lot::Mutex; use pretty_assertions::{assert_eq, assert_ne}; @@ -28633,6 +28634,130 @@ async fn test_sticky_scroll(cx: &mut TestAppContext) { assert_eq!(sticky_headers(10.0), vec![]); } +#[gpui::test] +fn test_relative_line_numbers(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let buffer_1 = cx.new(|cx| Buffer::local("aaaaaaaaaa\nbbb\n", cx)); + let buffer_2 = cx.new(|cx| Buffer::local("cccccccccc\nddd\n", cx)); + let buffer_3 = cx.new(|cx| Buffer::local("eee\nffffffffff\n", cx)); + + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.push_excerpts( + buffer_1.clone(), + [ExcerptRange::new(Point::new(0, 0)..Point::new(2, 0))], + cx, + ); + multibuffer.push_excerpts( + buffer_2.clone(), + [ExcerptRange::new(Point::new(0, 0)..Point::new(2, 0))], + cx, + ); + multibuffer.push_excerpts( + buffer_3.clone(), + [ExcerptRange::new(Point::new(0, 0)..Point::new(2, 0))], + cx, + ); + multibuffer + }); + + // wrapped contents of multibuffer: + // aaa + // aaa + // aaa + // a + // bbb + // + // ccc + // ccc + // ccc + // c + // ddd + // + // eee + // fff + // fff + // fff + // f + + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(multibuffer, window, cx)); + editor.update_in(cx, |editor, window, cx| { + editor.set_wrap_width(Some(30.0.into()), cx); // every 3 characters + + // includes trailing newlines. + let expected_line_numbers = [2, 6, 7, 10, 14, 15, 18, 19, 23]; + let expected_wrapped_line_numbers = [ + 2, 3, 4, 5, 6, 7, 10, 11, 12, 13, 14, 15, 18, 19, 20, 21, 22, 23, + ]; + + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([ + Point::new(7, 0)..Point::new(7, 1), // second row of `ccc` + ]); + }); + + let snapshot = editor.snapshot(window, cx); + + // these are all 0-indexed + let base_display_row = DisplayRow(11); + let base_row = 3; + let wrapped_base_row = 7; + + // test not counting wrapped lines + let expected_relative_numbers = expected_line_numbers + .into_iter() + .enumerate() + .map(|(i, row)| (DisplayRow(row), i.abs_diff(base_row) as u32)) + .collect_vec(); + let actual_relative_numbers = snapshot + .calculate_relative_line_numbers( + &(DisplayRow(0)..DisplayRow(24)), + base_display_row, + false, + ) + .into_iter() + .sorted() + .collect_vec(); + assert_eq!(expected_relative_numbers, actual_relative_numbers); + // check `calculate_relative_line_numbers()` against `relative_line_delta()` for each line + for (display_row, relative_number) in expected_relative_numbers { + assert_eq!( + relative_number, + snapshot + .relative_line_delta(display_row, base_display_row) + .unsigned_abs() as u32, + ); + } + + // test counting wrapped lines + let expected_wrapped_relative_numbers = expected_wrapped_line_numbers + .into_iter() + .enumerate() + .map(|(i, row)| (DisplayRow(row), i.abs_diff(wrapped_base_row) as u32)) + .collect_vec(); + let actual_relative_numbers = snapshot + .calculate_relative_line_numbers( + &(DisplayRow(0)..DisplayRow(24)), + base_display_row, + true, + ) + .into_iter() + .sorted() + .collect_vec(); + assert_eq!(expected_wrapped_relative_numbers, actual_relative_numbers); + // check `calculate_relative_line_numbers()` against `relative_wrapped_line_delta()` for each line + for (display_row, relative_number) in expected_wrapped_relative_numbers { + assert_eq!( + relative_number, + snapshot + .relative_wrapped_line_delta(display_row, base_display_row) + .unsigned_abs() as u32, + ); + } + }); +} + #[gpui::test] async fn test_scroll_by_clicking_sticky_header(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 4c3b44335bcad10be4303d545a8d2ad505938098..b2e355dc5158214eabd07d519649591be8a325a8 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -66,7 +66,7 @@ use project::{ }; use settings::{ GitGutterSetting, GitHunkStyleSetting, IndentGuideBackgroundColoring, IndentGuideColoring, - Settings, + RelativeLineNumbers, Settings, }; use smallvec::{SmallVec, smallvec}; use std::{ @@ -194,8 +194,6 @@ pub struct EditorElement { style: EditorStyle, } -type DisplayRowDelta = u32; - impl EditorElement { pub(crate) const SCROLLBAR_WIDTH: Pixels = px(15.); @@ -3225,64 +3223,6 @@ impl EditorElement { .collect() } - fn calculate_relative_line_numbers( - &self, - snapshot: &EditorSnapshot, - rows: &Range, - relative_to: Option, - count_wrapped_lines: bool, - ) -> HashMap { - let mut relative_rows: HashMap = Default::default(); - let Some(relative_to) = relative_to else { - return relative_rows; - }; - - let start = rows.start.min(relative_to); - let end = rows.end.max(relative_to); - - let buffer_rows = snapshot - .row_infos(start) - .take(1 + end.minus(start) as usize) - .collect::>(); - - let head_idx = relative_to.minus(start); - let mut delta = 1; - let mut i = head_idx + 1; - let should_count_line = |row_info: &RowInfo| { - if count_wrapped_lines { - row_info.buffer_row.is_some() || row_info.wrapped_buffer_row.is_some() - } else { - row_info.buffer_row.is_some() - } - }; - while i < buffer_rows.len() as u32 { - if should_count_line(&buffer_rows[i as usize]) { - if rows.contains(&DisplayRow(i + start.0)) { - relative_rows.insert(DisplayRow(i + start.0), delta); - } - delta += 1; - } - i += 1; - } - delta = 1; - i = head_idx.min(buffer_rows.len().saturating_sub(1) as u32); - while i > 0 && buffer_rows[i as usize].buffer_row.is_none() && !count_wrapped_lines { - i -= 1; - } - - while i > 0 { - i -= 1; - if should_count_line(&buffer_rows[i as usize]) { - if rows.contains(&DisplayRow(i + start.0)) { - relative_rows.insert(DisplayRow(i + start.0), delta); - } - delta += 1; - } - } - - relative_rows - } - fn layout_line_numbers( &self, gutter_hitbox: Option<&Hitbox>, @@ -3292,7 +3232,7 @@ impl EditorElement { rows: Range, buffer_rows: &[RowInfo], active_rows: &BTreeMap, - newest_selection_head: Option, + relative_line_base: Option, snapshot: &EditorSnapshot, window: &mut Window, cx: &mut App, @@ -3304,32 +3244,16 @@ impl EditorElement { return Arc::default(); } - let (newest_selection_head, relative) = self.editor.update(cx, |editor, cx| { - let newest_selection_head = newest_selection_head.unwrap_or_else(|| { - let newest = editor - .selections - .newest::(&editor.display_snapshot(cx)); - SelectionLayout::new( - newest, - editor.selections.line_mode(), - editor.cursor_offset_on_selection, - editor.cursor_shape, - &snapshot.display_snapshot, - true, - true, - None, - ) - .head - }); - let relative = editor.relative_line_numbers(cx); - (newest_selection_head, relative) - }); + let relative = self.editor.read(cx).relative_line_numbers(cx); let relative_line_numbers_enabled = relative.enabled(); - let relative_to = relative_line_numbers_enabled.then(|| newest_selection_head.row()); + let relative_rows = if relative_line_numbers_enabled && let Some(base) = relative_line_base + { + snapshot.calculate_relative_line_numbers(&rows, base, relative.wrapped()) + } else { + Default::default() + }; - let relative_rows = - self.calculate_relative_line_numbers(snapshot, &rows, relative_to, relative.wrapped()); let mut line_number = String::new(); let segments = buffer_rows.iter().enumerate().flat_map(|(ix, row_info)| { let display_row = DisplayRow(rows.start.0 + ix as u32); @@ -4652,6 +4576,8 @@ impl EditorElement { gutter_hitbox: &Hitbox, text_hitbox: &Hitbox, style: &EditorStyle, + relative_line_numbers: RelativeLineNumbers, + relative_to: Option, window: &mut Window, cx: &mut App, ) -> Option { @@ -4681,9 +4607,21 @@ impl EditorElement { ); let line_number = show_line_numbers.then(|| { - let number = (start_point.row + 1).to_string(); + let relative_number = relative_to.and_then(|base| match relative_line_numbers { + RelativeLineNumbers::Disabled => None, + RelativeLineNumbers::Enabled => { + Some(snapshot.relative_line_delta_to_point(base, start_point)) + } + RelativeLineNumbers::Wrapped => { + Some(snapshot.relative_wrapped_line_delta_to_point(base, start_point)) + } + }); + let number = relative_number + .filter(|&delta| delta != 0) + .map(|delta| delta.unsigned_abs() as u32) + .unwrap_or(start_point.row + 1); let color = cx.theme().colors().editor_line_number; - self.shape_line_number(SharedString::from(number), color, window) + self.shape_line_number(SharedString::from(number.to_string()), color, window) }); lines.push(StickyHeaderLine::new( @@ -9436,6 +9374,28 @@ impl Element for EditorElement { window, cx, ); + + // relative rows are based on newest selection, even outside the visible area + let relative_row_base = self.editor.update(cx, |editor, cx| { + if editor.selections.count()==0 { + return None; + } + let newest = editor + .selections + .newest::(&editor.display_snapshot(cx)); + Some(SelectionLayout::new( + newest, + editor.selections.line_mode(), + editor.cursor_offset_on_selection, + editor.cursor_shape, + &snapshot.display_snapshot, + true, + true, + None, + ) + .head.row()) + }); + let mut breakpoint_rows = self.editor.update(cx, |editor, cx| { editor.active_breakpoints(start_row..end_row, window, cx) }); @@ -9453,7 +9413,7 @@ impl Element for EditorElement { start_row..end_row, &row_infos, &active_rows, - newest_selection_head, + relative_row_base, &snapshot, window, cx, @@ -9773,6 +9733,7 @@ impl Element for EditorElement { && is_singleton && EditorSettings::get_global(cx).sticky_scroll.enabled { + let relative = self.editor.read(cx).relative_line_numbers(cx); self.layout_sticky_headers( &snapshot, editor_width, @@ -9784,6 +9745,8 @@ impl Element for EditorElement { &gutter_hitbox, &text_hitbox, &style, + relative, + relative_row_base, window, cx, ) @@ -11631,7 +11594,7 @@ mod tests { } #[gpui::test] - fn test_shape_line_numbers(cx: &mut TestAppContext) { + fn test_layout_line_numbers(cx: &mut TestAppContext) { init_test(cx, |_| {}); let window = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple(&sample_text(6, 6, 'a'), cx); @@ -11671,7 +11634,7 @@ mod tests { }) .collect::>(), &BTreeMap::default(), - Some(DisplayPoint::new(DisplayRow(0), 0)), + Some(DisplayRow(0)), &snapshot, window, cx, @@ -11683,10 +11646,9 @@ mod tests { let relative_rows = window .update(cx, |editor, window, cx| { let snapshot = editor.snapshot(window, cx); - element.calculate_relative_line_numbers( - &snapshot, + snapshot.calculate_relative_line_numbers( &(DisplayRow(0)..DisplayRow(6)), - Some(DisplayRow(3)), + DisplayRow(3), false, ) }) @@ -11702,10 +11664,9 @@ mod tests { let relative_rows = window .update(cx, |editor, window, cx| { let snapshot = editor.snapshot(window, cx); - element.calculate_relative_line_numbers( - &snapshot, + snapshot.calculate_relative_line_numbers( &(DisplayRow(3)..DisplayRow(6)), - Some(DisplayRow(1)), + DisplayRow(1), false, ) }) @@ -11719,10 +11680,9 @@ mod tests { let relative_rows = window .update(cx, |editor, window, cx| { let snapshot = editor.snapshot(window, cx); - element.calculate_relative_line_numbers( - &snapshot, + snapshot.calculate_relative_line_numbers( &(DisplayRow(0)..DisplayRow(3)), - Some(DisplayRow(6)), + DisplayRow(6), false, ) }) @@ -11759,7 +11719,7 @@ mod tests { }) .collect::>(), &BTreeMap::default(), - Some(DisplayPoint::new(DisplayRow(0), 0)), + Some(DisplayRow(0)), &snapshot, window, cx, @@ -11774,7 +11734,7 @@ mod tests { } #[gpui::test] - fn test_shape_line_numbers_wrapping(cx: &mut TestAppContext) { + fn test_layout_line_numbers_wrapping(cx: &mut TestAppContext) { init_test(cx, |_| {}); let window = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple(&sample_text(6, 6, 'a'), cx); @@ -11819,7 +11779,7 @@ mod tests { }) .collect::>(), &BTreeMap::default(), - Some(DisplayPoint::new(DisplayRow(0), 0)), + Some(DisplayRow(0)), &snapshot, window, cx, @@ -11831,10 +11791,9 @@ mod tests { let relative_rows = window .update(cx, |editor, window, cx| { let snapshot = editor.snapshot(window, cx); - element.calculate_relative_line_numbers( - &snapshot, + snapshot.calculate_relative_line_numbers( &(DisplayRow(0)..DisplayRow(6)), - Some(DisplayRow(3)), + DisplayRow(3), true, ) }) @@ -11871,7 +11830,7 @@ mod tests { }) .collect::>(), &BTreeMap::from_iter([(DisplayRow(0), LineHighlightSpec::default())]), - Some(DisplayPoint::new(DisplayRow(0), 0)), + Some(DisplayRow(0)), &snapshot, window, cx, @@ -11886,10 +11845,9 @@ mod tests { let relative_rows = window .update(cx, |editor, window, cx| { let snapshot = editor.snapshot(window, cx); - element.calculate_relative_line_numbers( - &snapshot, + snapshot.calculate_relative_line_numbers( &(DisplayRow(0)..DisplayRow(6)), - Some(DisplayRow(3)), + DisplayRow(3), true, ) })